You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@shenyu.apache.org by li...@apache.org on 2026/10/01 07:02:55 UTC

(shenyu) branch master updated: [feat] support Kubernetes Gateway API (#6347)

This is an automated email from the ASF dual-hosted git repository.

Aias00 pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu.git


The following commit(s) were added to refs/heads/master by this push:
     new a250c109b4 [feat] support Kubernetes Gateway API (#6347)
a250c109b4 is described below

commit a250c109b4ea1d669fd79bcce2dabbc88bd545e4
Author: eye-gu <ey...@apache.org>
AuthorDate: Thu Oct 1 15:02:48 2026 +0800

    [feat] support Kubernetes Gateway API (#6347)
---
 .../workflows/integrated-test-k8s-gateway-api.yml  | 183 +++++
 .../shenyu-examples-http/k8s/gateway-api.yml       |  53 ++
 shenyu-integrated-test/pom.xml                     |   1 +
 .../Dockerfile                                     |  29 +
 .../deploy/deploy-shenyu.yaml                      | 163 +++++
 .../deploy/kind-config.yaml                        |  19 +-
 .../pom.xml                                        | 144 ++++
 .../script/build_k8s_cluster.sh                    |  12 +-
 .../script/healthcheck.sh                          |  20 +-
 .../script/services.list                           |  12 +-
 .../gateway/api/GatewayApiControllerBootstrap.java |  37 +
 .../src/main/resources/application.yml             | 179 +++++
 .../test/k8s/gateway/api/DividePluginTest.java     |  45 ++
 shenyu-kubernetes-controller/pom.xml               |   7 +
 .../apache/shenyu/k8s/cache/GatewayRouteCache.java | 170 +++++
 .../apache/shenyu/k8s/cache/K8sCacheReadiness.java |  85 +++
 .../shenyu/k8s/common/GatewayApiConstants.java     | 186 +++++
 .../shenyu/k8s/common/GatewayApiCrdVerifier.java   | 142 ++++
 .../shenyu/k8s/common/GatewayApiCrdVersions.java   | 102 +++
 .../org/apache/shenyu/k8s/common/JsonFields.java   |  77 +++
 .../apache/shenyu/k8s/common/ListenerSupport.java  | 244 +++++++
 .../apache/shenyu/k8s/common/ReferenceGrants.java  | 106 +++
 .../shenyu/k8s/common/ShenyuMemoryConfig.java      |  33 +
 .../apache/shenyu/k8s/common/StatusMergePatch.java |  57 ++
 .../apache/shenyu/k8s/parser/HttpRouteParser.java  | 722 +++++++++++++++++++
 .../k8s/reconciler/GatewayClassReconciler.java     | 521 ++++++++++++++
 .../shenyu/k8s/reconciler/GatewayReconciler.java   | 513 ++++++++++++++
 .../shenyu/k8s/reconciler/HTTPRouteReconciler.java | 652 ++++++++++++++++++
 .../k8s/reconciler/HttpRouteBackendHandler.java    | 120 ++++
 .../k8s/reconciler/HttpRouteEndpointsHandler.java  |  49 ++
 .../k8s/reconciler/HttpRouteServiceHandler.java    |  52 ++
 .../k8s/reconciler/ReferenceGrantReconciler.java   | 117 ++++
 .../k8s/repository/ShenyuCacheRepository.java      | 113 ++-
 .../shenyu/k8s/GatewayClassReconcilerTest.java     | 405 +++++++++++
 .../apache/shenyu/k8s/GatewayReconcilerTest.java   | 402 +++++++++++
 .../apache/shenyu/k8s/HTTPRouteReconcilerTest.java | 676 ++++++++++++++++++
 .../shenyu/k8s/HttpRouteBackendHandlerTest.java    | 189 +++++
 .../org/apache/shenyu/k8s/HttpRouteParserTest.java | 763 +++++++++++++++++++++
 .../apache/shenyu/k8s/K8sCacheReadinessTest.java   |  98 +++
 .../shenyu/k8s/ShenyuCacheRepositoryTest.java      | 169 +++++
 .../k8s/common/GatewayApiCrdVerifierTest.java      | 121 ++++
 .../k8s/common/GatewayApiCrdVersionsTest.java      |  52 ++
 .../shenyu-spring-boot-starter-k8s/pom.xml         |   5 +
 .../starter/k8s/ControllerManagerLifecycle.java    |  61 ++
 .../k8s/GatewayApiControllerConfiguration.java     | 489 +++++++++++++
 .../k8s/IngressControllerConfiguration.java        |   1 +
 .../src/main/resources/META-INF/spring.factories   |   3 +-
 ...rk.boot.autoconfigure.AutoConfiguration.imports |   1 +
 48 files changed, 8378 insertions(+), 22 deletions(-)

diff --git a/.github/workflows/integrated-test-k8s-gateway-api.yml b/.github/workflows/integrated-test-k8s-gateway-api.yml
new file mode 100644
index 0000000000..28960e2e4f
--- /dev/null
+++ b/.github/workflows/integrated-test-k8s-gateway-api.yml
@@ -0,0 +1,183 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+name: it-k8s-gateway-api
+
+on:
+  pull_request:
+  push:
+    branches:
+      - master
+
+jobs:
+  build:
+    strategy:
+      matrix:
+        case:
+          - shenyu-integrated-test-k8s-gateway-api-http
+    runs-on: ubuntu-latest
+    if: github.repository == 'apache/shenyu'
+    steps:
+      - uses: actions/checkout@v3
+        with:
+          submodules: true
+
+      - uses: ./actions/paths-filter
+        id: filter
+        with:
+          filters: |
+            k8s-gateway-api:
+              - 'shenyu-integrated-test-k8s-gateway-api*/**'
+              - 'shenyu-*/**'
+              - 'pom.xml'
+              - '**/pom.xml'
+              - 'shenyu-examples/**'
+              - '!**/*.md'
+              - '!**/*.txt'
+              - '!resources/static/**'
+              - '!.asf.yaml'
+              - '!.gitignore'
+              - '!.licenserc.yaml'
+              - '!LICENSE'
+              - '!NOTICE'
+              - '!.github/ISSUE_TEMPLATE/**'
+              - '!.github/PULL_REQUEST_TEMPLATE'
+
+      - name: Clean Space
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        run: |
+          sudo rm -rf /usr/share/dotnet
+          sudo rm -rf /opt/ghc
+          sudo rm -rf "/usr/local/share/boost"
+          sudo rm -rf "$AGENT_TOOLSDIRECTORY"
+
+      - name: Cache Maven Repos
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        uses: actions/cache@v3
+        with:
+          path: ~/.m2/repository
+          key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }}
+          restore-keys: |
+            ${{ runner.os }}-maven-
+
+      - uses: actions/setup-java@v4
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        with:
+          java-version: 17
+          distribution: "temurin"
+
+      - name: Install Go
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        uses: actions/setup-go@v3
+        with:
+          go-version: 1.21.x
+
+      - name: Install k8s
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        run: |
+          go install sigs.k8s.io/kind@v0.24.0
+          curl -LO https://storage.googleapis.com/kubernetes-release/release/v1.31.0/bin/linux/amd64/kubectl && sudo install kubectl /usr/local/bin/kubectl
+          kind create cluster --image=kindest/node:v1.31.0 --config=./shenyu-integrated-test/${{ matrix.case }}/deploy/kind-config.yaml
+
+      - name: Install mvnd
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        shell: bash
+        run: |
+          MVND_VERSION=1.0.6
+          curl -sfL https://downloads.apache.org/maven/mvnd/${MVND_VERSION}/maven-mvnd-${MVND_VERSION}-linux-amd64.zip -o mvnd.zip
+          unzip -q mvnd.zip
+          mkdir -p $HOME/.local
+          mv maven-mvnd-${MVND_VERSION}-linux-amd64 $HOME/.local/mvnd
+          echo "$HOME/.local/mvnd/bin" >> $GITHUB_PATH
+          echo "MVND_HOME=$HOME/.local/mvnd" >> $GITHUB_ENV
+
+      - name: Build with Maven
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        shell: bash
+        run: |
+          if mvnd --version > /dev/null 2>&1; then
+            echo "Using mvnd for build"
+            mvnd -B clean install -Dmaven.javadoc.skip=true -Dmaven.test.skip=true
+          else
+            echo "Falling back to maven wrapper"
+            ./mvnw -B clean install -Dmaven.javadoc.skip=true -Dmaven.test.skip=true
+          fi
+
+      - name: Build integrated tests
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        shell: bash
+        run: |
+          if mvnd --version > /dev/null 2>&1; then
+            echo "Using mvnd for build integrated tests"
+            mvnd -B clean install -Pit -DskipTests -am -f ./shenyu-integrated-test/pom.xml
+          else
+            echo "Falling back to maven wrapper for integrated tests"
+            ./mvnw -B clean install -Pit -DskipTests -am -f ./shenyu-integrated-test/pom.xml
+          fi
+
+      - name: Build examples
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        shell: bash
+        run: |
+          if mvnd --version > /dev/null 2>&1; then
+            echo "Using mvnd for build examples"
+            mvnd -B clean install -Pexample -Dmaven.javadoc.skip=true -Dmaven.test.skip=true -am -f ./shenyu-examples/pom.xml
+          else
+            echo "Falling back to maven wrapper for examples"
+            ./mvnw -B clean install -Pexample -Dmaven.javadoc.skip=true -Dmaven.test.skip=true -am -f ./shenyu-examples/pom.xml
+          fi
+
+      - name: Build k8s Cluster
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        run: bash ./shenyu-integrated-test/${{ matrix.case }}/script/build_k8s_cluster.sh
+
+      - name: Wait for k8s Cluster Start up
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        timeout-minutes: 15
+        run: |
+          bash ./shenyu-integrated-test/${{ matrix.case }}/script/healthcheck.sh
+
+      - name: Run test
+        id: test
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        shell: bash
+        run: |
+          if mvnd --version > /dev/null 2>&1; then
+            echo "Using mvnd for running tests"
+            mvnd test -Pit -f ./shenyu-integrated-test/${{ matrix.case }}/pom.xml
+          else
+            echo "Falling back to maven wrapper for tests"
+            ./mvnw test -Pit -f ./shenyu-integrated-test/${{ matrix.case }}/pom.xml
+          fi
+        continue-on-error: true
+
+      - name: Cluster Test after Healthcheck
+        if: steps.filter.outputs.k8s-gateway-api == 'true'
+        run: |
+          echo "----------kubectl get all -o wide----------"
+          kubectl get all -o wide
+          echo "----------kubectl get all -n shenyu-gateway-api -o wide----------"
+          kubectl get all -n shenyu-gateway-api -o wide
+          echo "----------kubectl get events --all-namespaces----------"
+          kubectl get events --all-namespaces
+          echo "----------kubectl logs -l app=shenyu-gateway-api-controller -n shenyu-gateway-api --tail=-1----------"
+          kubectl logs -l app=shenyu-gateway-api-controller -n shenyu-gateway-api --tail=-1
+          if [[ ${{steps.test.outcome}} == "failure" ]]; then
+            echo "Test Failed"
+            exit 1
+          else
+            echo "Test Successful"
+            exit 0
+          fi
diff --git a/shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml b/shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml
new file mode 100644
index 0000000000..e0d9ca0ba9
--- /dev/null
+++ b/shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml
@@ -0,0 +1,53 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the
+# "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+apiVersion: gateway.networking.k8s.io/v1
+kind: GatewayClass
+metadata:
+  name: shenyu
+spec:
+  controllerName: gateway.shenyu.apache.org/shenyu-controller
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: Gateway
+metadata:
+  name: shenyu-gateway
+spec:
+  gatewayClassName: shenyu
+  listeners:
+    - name: http
+      port: 9195
+      protocol: HTTP
+      allowedRoutes:
+        namespaces:
+          from: Same
+---
+apiVersion: gateway.networking.k8s.io/v1
+kind: HTTPRoute
+metadata:
+  name: demo-http-route
+spec:
+  parentRefs:
+    - name: shenyu-gateway
+  rules:
+    - matches:
+        - path:
+            type: PathPrefix
+            value: /order
+      backendRefs:
+        - name: shenyu-examples-http-service
+          port: 8189
diff --git a/shenyu-integrated-test/pom.xml b/shenyu-integrated-test/pom.xml
index 341ff505c1..4de30e33a3 100644
--- a/shenyu-integrated-test/pom.xml
+++ b/shenyu-integrated-test/pom.xml
@@ -47,6 +47,7 @@
         <module>shenyu-integrated-test-k8s-ingress-websocket</module>
         <module>shenyu-integrated-test-k8s-ingress-grpc</module>
         <module>shenyu-integrated-test-k8s-ingress-sofa</module>
+        <module>shenyu-integrated-test-k8s-gateway-api-http</module>
     </modules>
 
     <properties>
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/Dockerfile b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/Dockerfile
new file mode 100644
index 0000000000..d11d2e4d81
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/Dockerfile
@@ -0,0 +1,29 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+FROM eclipse-temurin:17-centos7
+
+ENV APP_NAME shenyu-integrated-test-k8s-gateway-api-http
+ENV LOCAL_PATH /opt/${APP_NAME}
+
+RUN mkdir -p ${LOCAL_PATH}
+
+ADD target/${APP_NAME}.jar ${LOCAL_PATH}
+
+WORKDIR ${LOCAL_PATH}
+EXPOSE 9195
+
+CMD java -jar ${APP_NAME}.jar
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml
new file mode 100644
index 0000000000..f3adf71b66
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml
@@ -0,0 +1,163 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+apiVersion: v1
+kind: Namespace
+metadata:
+  name: shenyu-gateway-api
+---
+apiVersion: v1
+automountServiceAccountToken: true
+kind: ServiceAccount
+metadata:
+  name: shenyu-gateway-api-controller
+  namespace: shenyu-gateway-api
+---
+apiVersion: apps/v1
+kind: Deployment
+metadata:
+  name: shenyu-gateway-api-controller
+  namespace: shenyu-gateway-api
+  labels:
+    app: shenyu-gateway-api-controller
+    all: shenyu-gateway-api-controller
+spec:
+  replicas: 2
+  strategy:
+    rollingUpdate:
+      maxSurge: 1
+      maxUnavailable: 0
+  selector:
+    matchLabels:
+      app: shenyu-gateway-api-controller
+  template:
+    metadata:
+      labels:
+        app: shenyu-gateway-api-controller
+    spec:
+      containers:
+      - name: shenyu-gateway-api-controller
+        image: apache/shenyu-integrated-test-k8s-gateway-api-http:latest
+        ports:
+        - containerPort: 9195
+        readinessProbe:
+          httpGet:
+            path: /actuator/health/readiness
+            port: 9195
+          initialDelaySeconds: 10
+          periodSeconds: 3
+          failureThreshold: 40
+        imagePullPolicy: IfNotPresent
+      serviceAccountName: shenyu-gateway-api-controller
+---
+apiVersion: policy/v1
+kind: PodDisruptionBudget
+metadata:
+  name: shenyu-gateway-api-controller
+  namespace: shenyu-gateway-api
+spec:
+  minAvailable: 1
+  selector:
+    matchLabels:
+      app: shenyu-gateway-api-controller
+---
+apiVersion: v1
+kind: Service
+metadata:
+  name: shenyu-gateway-api-controller
+  namespace: shenyu-gateway-api
+spec:
+  selector:
+    app: shenyu-gateway-api-controller
+  type: NodePort
+  ports:
+    - port: 9195
+      targetPort: 9195
+      nodePort: 30095
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRole
+metadata:
+  name: shenyu-gateway-api-controller
+rules:
+# Services are watched to map a backendRef's Service port to its targetPort
+# (including named targetPorts); Endpoints (sharing name/namespace with the
+# backed Service) are watched to resolve backendRefs.
+- apiGroups:
+  - ""
+  resources:
+  - services
+  - endpoints
+  verbs:
+  - get
+  - list
+  - watch
+# patch on gatewayclasses manages the spec-defined finalizer while Gateways use the class
+- apiGroups:
+  - gateway.networking.k8s.io
+  resources:
+  - gatewayclasses
+  - gateways
+  - httproutes
+  verbs:
+  - get
+  - list
+  - watch
+- apiGroups:
+  - gateway.networking.k8s.io
+  resources:
+  - gatewayclasses
+  verbs:
+  - patch
+- apiGroups:
+  - gateway.networking.k8s.io
+  resources:
+  - gatewayclasses/status
+  - gateways/status
+  - httproutes/status
+  verbs:
+  - get
+  - patch
+# Required for cross-namespace parentRef validation via ReferenceGrant
+- apiGroups:
+  - gateway.networking.k8s.io
+  resources:
+  - referencegrants
+  verbs:
+  - get
+  - list
+  - watch
+# Required to read the bundle-version annotation for the SupportedVersion condition
+- apiGroups:
+  - apiextensions.k8s.io
+  resources:
+  - customresourcedefinitions
+  verbs:
+  - get
+---
+apiVersion: rbac.authorization.k8s.io/v1
+kind: ClusterRoleBinding
+metadata:
+  name: shenyu-gateway-api-controller
+  namespace: shenyu-gateway-api
+roleRef:
+  apiGroup: rbac.authorization.k8s.io
+  kind: ClusterRole
+  name: shenyu-gateway-api-controller
+subjects:
+- kind: ServiceAccount
+  name: shenyu-gateway-api-controller
+  namespace: shenyu-gateway-api
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml
similarity index 59%
copy from shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
copy to shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml
index 3eb3d33916..6b01013ef5 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/kind-config.yaml
@@ -1,9 +1,10 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one or more
-# contributor license agreements.  See the NOTICE file distributed with
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with
 # this work for additional information regarding copyright ownership.
 # The ASF licenses this file to You under the Apache License, Version 2.0
-# (the "License"); you may not use this file except in compliance with
+# (the
+# "License"); you may not use this file except in compliance with
 # the License.  You may obtain a copy of the License at
 #
 #     http://www.apache.org/licenses/LICENSE-2.0
@@ -13,6 +14,12 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-#
 
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+kind: Cluster
+apiVersion: kind.x-k8s.io/v1alpha4
+nodes:
+  - role: control-plane
+    extraPortMappings:
+      - containerPort: 30095
+        hostPort: 30095
+        protocol: TCP
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/pom.xml b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/pom.xml
new file mode 100644
index 0000000000..705abdbfa7
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/pom.xml
@@ -0,0 +1,144 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!--
+  ~ Licensed to the Apache Software Foundation (ASF) under one or more
+  ~ contributor license agreements.  See the NOTICE file distributed with
+  ~ this work for additional information regarding copyright ownership.
+  ~ The ASF licenses this file to You under the Apache License, Version 2.0
+  ~ (the "License"); you may not use this file except in compliance with
+  ~ the License.  You may obtain a copy of the License at
+  ~
+  ~     http://www.apache.org/licenses/LICENSE-2.0
+  ~
+  ~ Unless required by applicable law or agreed to in writing, software
+  ~ distributed under the License is distributed on an "AS IS" BASIS,
+  ~ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+  ~ See the License for the specific language governing permissions and
+  ~ limitations under the License.
+  -->
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <parent>
+        <artifactId>shenyu-integrated-test</artifactId>
+        <groupId>org.apache.shenyu</groupId>
+        <version>2.7.2-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>shenyu-integrated-test-k8s-gateway-api-http</artifactId>
+    <name>shenyu-integrated-test-k8s-gateway-api-http</name>
+    <url>https://shenyu.apache.org</url>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-integrated-test-common</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-spring-boot-starter-plugin-divide</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-spring-boot-starter-plugin-httpclient</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-spring-boot-starter-plugin-global</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-spring-boot-starter-plugin-uri</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-spring-boot-starter-plugin-general-context</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-spring-boot-starter-k8s</artifactId>
+            <version>${project.version}</version>
+        </dependency>
+
+        <dependency>
+            <groupId>com.google.code.gson</groupId>
+            <artifactId>gson</artifactId>
+        </dependency>
+    </dependencies>
+
+    <profiles>
+        <profile>
+            <id>it</id>
+            <properties>
+                <docker.buildArg.APP_NAME>shenyu-integrated-test-k8s-gateway-api-http</docker.buildArg.APP_NAME>
+                <docker.image.tag.repo>apache/shenyu-integrated-test-k8s-gateway-api-http</docker.image.tag.repo>
+                <docker.image.tag.tagName>latest</docker.image.tag.tagName>
+            </properties>
+            <build>
+                <finalName>shenyu-integrated-test-k8s-gateway-api-http</finalName>
+                <plugins>
+                    <plugin>
+                        <groupId>org.springframework.boot</groupId>
+                        <artifactId>spring-boot-maven-plugin</artifactId>
+                        <version>${spring-boot.version}</version>
+                        <executions>
+                            <execution>
+                                <phase>package</phase>
+                                <goals>
+                                    <goal>repackage</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                        <configuration>
+                            <mainClass>org.apache.shenyu.integrated.test.k8s.gateway.api.GatewayApiControllerBootstrap</mainClass>
+                            <executable>true</executable>
+                        </configuration>
+                    </plugin>
+                    <plugin>
+                        <groupId>io.fabric8</groupId>
+                        <artifactId>docker-maven-plugin</artifactId>
+                        <version>${docker-maven-plugin.version}</version>
+                        <configuration>
+                            <images>
+                                <image>
+                                    <name>apache/shenyu-integrated-test-k8s-gateway-api-http</name>
+                                    <build>
+                                        <contextDir>${project.basedir}</contextDir>
+                                    </build>
+                                </image>
+                            </images>
+                        </configuration>
+                        <executions>
+                            <execution>
+                                <id>start</id>
+                                <goals>
+                                    <goal>build</goal>
+                                </goals>
+                            </execution>
+                        </executions>
+                    </plugin>
+
+                    <plugin>
+                        <groupId>org.apache.maven.plugins</groupId>
+                        <artifactId>maven-surefire-plugin</artifactId>
+                        <configuration>
+                            <skipTests>false</skipTests>
+                        </configuration>
+                    </plugin>
+                </plugins>
+            </build>
+        </profile>
+    </profiles>
+</project>
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh
similarity index 56%
copy from shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
copy to shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh
index 3eb3d33916..4e1b5c93a9 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/build_k8s_cluster.sh
@@ -1,3 +1,5 @@
+#!/bin/bash
+set -e
 #
 # Licensed to the Apache Software Foundation (ASF) under one or more
 # contributor license agreements.  See the NOTICE file distributed with
@@ -15,4 +17,12 @@
 # limitations under the License.
 #
 
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+kind load docker-image "shenyu-examples-http:latest"
+kind load docker-image "apache/shenyu-integrated-test-k8s-gateway-api-http:latest"
+
+# Install Gateway API CRD before creating Gateway/HTTPRoute resources
+kubectl apply -f https://github.com/kubernetes-sigs/gateway-api/releases/download/v1.5.1/standard-install.yaml
+
+kubectl apply -f ./shenyu-examples/shenyu-examples-http/k8s/shenyu-examples-http.yml
+kubectl apply -f ./shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/deploy/deploy-shenyu.yaml
+kubectl apply -f ./shenyu-examples/shenyu-examples-http/k8s/gateway-api.yml
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh
similarity index 67%
copy from shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
copy to shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh
index 3eb3d33916..896e784144 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/healthcheck.sh
@@ -1,3 +1,4 @@
+#!/bin/bash
 #
 # Licensed to the Apache Software Foundation (ASF) under one or more
 # contributor license agreements.  See the NOTICE file distributed with
@@ -15,4 +16,21 @@
 # limitations under the License.
 #
 
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+PRGDIR=`dirname "$0"`
+for service in `grep -v -E "^$|^#" ${PRGDIR}/services.list`
+do
+    for loop in `seq 1 30`
+    do
+        status=`curl -o /dev/null -s -w %{http_code} $service`
+        echo -e "curl $service response $status"
+
+        if [ $status -eq 200  ]; then
+            break
+        fi
+
+        sleep 2
+    done
+done
+
+sleep 20
+echo -e "\n-------------------"
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list
similarity index 66%
copy from shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
copy to shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list
index 3eb3d33916..6da1cfd997 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/script/services.list
@@ -1,9 +1,10 @@
-#
-# Licensed to the Apache Software Foundation (ASF) under one or more
-# contributor license agreements.  See the NOTICE file distributed with
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with
 # this work for additional information regarding copyright ownership.
 # The ASF licenses this file to You under the Apache License, Version 2.0
-# (the "License"); you may not use this file except in compliance with
+# (the
+# "License"); you may not use this file except in compliance with
 # the License.  You may obtain a copy of the License at
 #
 #     http://www.apache.org/licenses/LICENSE-2.0
@@ -13,6 +14,5 @@
 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 # See the License for the specific language governing permissions and
 # limitations under the License.
-#
 
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+http://localhost:30095/actuator/health
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/java/org/apache/shenyu/integrated/test/k8s/gateway/api/GatewayApiControllerBootstrap.java b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/java/org/apache/shenyu/integrated/test/k8s/gateway/api/GatewayApiControllerBootstrap.java
new file mode 100644
index 0000000000..4f69d0f9e4
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/java/org/apache/shenyu/integrated/test/k8s/gateway/api/GatewayApiControllerBootstrap.java
@@ -0,0 +1,37 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.integrated.test.k8s.gateway.api;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * The type gateway api controller integrated bootstrap.
+ */
+@SpringBootApplication
+public class GatewayApiControllerBootstrap {
+
+    /**
+     * main method of App.
+     *
+     * @param args args
+     */
+    public static void main(final String[] args) {
+        SpringApplication.run(GatewayApiControllerBootstrap.class);
+    }
+}
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/resources/application.yml b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/resources/application.yml
new file mode 100644
index 0000000000..a1c10bccb9
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/main/resources/application.yml
@@ -0,0 +1,179 @@
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+server:
+  port: 9195
+  address: 0.0.0.0
+
+spring:
+  main:
+    allow-bean-definition-overriding: true
+  application:
+    name: shenyu-bootstrap
+  codec:
+    max-in-memory-size: 2MB
+  cloud:
+    discovery:
+      enabled: false
+    nacos:
+      discovery:
+        server-addr: 127.0.0.1:8848
+        enabled: false
+        namespace: ShenyuRegisterCenter
+
+eureka:
+  client:
+    enabled: false
+    serviceUrl:
+      defaultZone: http://localhost:8761/eureka/
+  instance:
+    prefer-ip-address: true
+
+management:
+  health:
+    redis:
+      enabled: false
+    elasticsearch:
+      enabled: false
+  endpoint:
+    health:
+      enabled: true
+      show-details: always
+      probes:
+        enabled: true
+      group:
+        readiness:
+          include: readinessState,k8sCacheReadiness
+  endpoints:
+    web:
+      exposure:
+        include: "*"
+
+shenyu:
+  k8s:
+    mode: gateway-api
+  selectorMatchCache:
+    cache:
+      enabled: false
+      initialCapacity: 10000
+      maximumSize: 10000
+  ruleMatchCache:
+    cache:
+      enabled: true
+      initialCapacity: 10000
+      maximumSize: 65536
+  netty:
+    http:
+      webServerFactoryEnabled: true
+      selectCount: 1
+      workerCount: 8
+      accessLog: false
+      serverSocketChannel:
+        soRcvBuf: 87380
+        soBackLog: 128
+        soReuseAddr: false
+        connectTimeoutMillis: 10000
+        writeBufferHighWaterMark: 65536
+        writeBufferLowWaterMark: 32768
+        writeSpinCount: 16
+        autoRead: false
+        allocType: "pooled"
+        messageSizeEstimator: 8
+        singleEventExecutorPerGroup: true
+      socketChannel:
+        soKeepAlive: false
+        soReuseAddr: false
+        soLinger: -1
+        tcpNoDelay: true
+        soRcvBuf: 87380
+        soSndBuf: 16384
+        ipTos: 0
+        allowHalfClosure: false
+        connectTimeoutMillis: 10000
+        writeBufferHighWaterMark: 65536
+        writeBufferLowWaterMark: 32768
+        writeSpinCount: 16
+        autoRead: false
+        allocType: "pooled"
+        messageSizeEstimator: 8
+        singleEventExecutorPerGroup: true
+  httpclient:
+    strategy: netty
+  register:
+    enabled: false
+    registerType: zookeeper
+    serverLists: localhost:2181
+    props:
+  cross:
+    enabled: true
+    allowedHeaders:
+    allowedMethods: "*"
+    allowedAnyOrigin: true
+    allowedExpose: ""
+    maxAge: "18000"
+    allowCredentials: true
+
+  switchConfig:
+    local: true
+    collapseSlashes: false
+  file:
+    enabled: true
+    maxSize: 10
+  health:
+    enabled: true
+    paths:
+      - /actuator
+      - /health_check
+  extPlugin:
+    path:
+    enabled: true
+    threads: 1
+    scheduleTime: 300
+    scheduleDelay: 30
+  scheduler:
+    enabled: false
+    type: fixed
+    threads: 16
+  upstreamCheck:
+    enabled: false
+    poolSize: 10
+    timeout: 3000
+    healthyThreshold: 1
+    unhealthyThreshold: 1
+    interval: 5000
+    printEnabled: true
+    printInterval: 60000
+  springCloudCache:
+    enabled: false
+  ribbon:
+    serverListRefreshInterval: 10000
+  metrics:
+    enabled: false
+    name: prometheus
+    host: 127.0.0.1
+    port: 8090
+    jmxConfig:
+    props:
+      jvm_enabled: true
+  local:
+    enabled: false
+    sha512Key: "BA3253876AED6BC22D4A6FF53D8406C6AD864195ED144AB5C87621B6C233B548BAEAE6956DF346EC8C17F5EA10F35EE3CBC514797ED7DDD3145464E2A0BAB413"
+
+logging:
+  level:
+    root: info
+    org.springframework.boot: info
+    org.apache.ibatis: info
+    org.apache.shenyu: info
diff --git a/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/test/java/org/apache/shenyu/integrated/test/k8s/gateway/api/DividePluginTest.java b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/test/java/org/apache/shenyu/integrated/test/k8s/gateway/api/DividePluginTest.java
new file mode 100644
index 0000000000..42b44db0b7
--- /dev/null
+++ b/shenyu-integrated-test/shenyu-integrated-test-k8s-gateway-api-http/src/test/java/org/apache/shenyu/integrated/test/k8s/gateway/api/DividePluginTest.java
@@ -0,0 +1,45 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.integrated.test.k8s.gateway.api;
+
+import org.apache.shenyu.integratedtest.common.dto.OrderDTO;
+import org.apache.shenyu.integratedtest.common.helper.HttpHelper;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+/**
+ * Test divide plugin in gateway api controller.
+ */
+public class DividePluginTest {
+
+    private static final HttpHelper HTTP_HELPER = HttpHelper.INSTANCE;
+
+    @BeforeAll
+    public static void setup() {
+        HTTP_HELPER.setGatewayEndpoint("http://localhost:30095");
+    }
+
+    @Test
+    public void testHelloWorld() throws Exception {
+        OrderDTO user = new OrderDTO("123", "Tom");
+        user = HttpHelper.INSTANCE.postGateway("/order/save", user, OrderDTO.class);
+        assertEquals("hello world save order", user.getName());
+    }
+}
diff --git a/shenyu-kubernetes-controller/pom.xml b/shenyu-kubernetes-controller/pom.xml
index 72a062f013..8a54b2c8c1 100644
--- a/shenyu-kubernetes-controller/pom.xml
+++ b/shenyu-kubernetes-controller/pom.xml
@@ -47,5 +47,12 @@
             <groupId>io.kubernetes</groupId>
             <artifactId>client-java-spring-integration</artifactId>
         </dependency>
+
+        <dependency>
+            <groupId>org.apache.shenyu</groupId>
+            <artifactId>shenyu-plugin-divide</artifactId>
+            <version>${project.version}</version>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 </project>
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/GatewayRouteCache.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/GatewayRouteCache.java
new file mode 100644
index 0000000000..49988dd9f4
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/GatewayRouteCache.java
@@ -0,0 +1,170 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.cache;
+
+import com.google.common.collect.Maps;
+import com.google.common.collect.Sets;
+
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * In-memory route↔gateway/listener bindings written by the HTTPRoute reconciler, read for
+ * attachedRoutes counts and cascade cleanup. Tracked per listener because the spec defines
+ * attachedRoutes per listener.
+ */
+public final class GatewayRouteCache {
+
+    private static final GatewayRouteCache INSTANCE = new GatewayRouteCache();
+
+    private static final Map<String, List<String>> ROUTE_SELECTOR_MAP = Maps.newConcurrentMap();
+
+    /** gatewayKey → (routeKey → listener names that accepted the route). */
+    private static final Map<String, Map<String, Set<String>>> GATEWAY_ROUTE_MAP = Maps.newConcurrentMap();
+
+    private GatewayRouteCache() {
+    }
+
+    public static GatewayRouteCache getInstance() {
+        return INSTANCE;
+    }
+
+    public void putRouteSelectors(final String namespace, final String routeName,
+                                  final String pluginName, final List<String> selectorIds) {
+        ROUTE_SELECTOR_MAP.put(routeKey(namespace, routeName, pluginName), selectorIds);
+    }
+
+    public List<String> getRouteSelectors(final String namespace, final String routeName,
+                                          final String pluginName) {
+        return ROUTE_SELECTOR_MAP.get(routeKey(namespace, routeName, pluginName));
+    }
+
+    public List<String> removeRouteSelectors(final String namespace, final String routeName,
+                                             final String pluginName) {
+        return ROUTE_SELECTOR_MAP.remove(routeKey(namespace, routeName, pluginName));
+    }
+
+    /**
+     * Bind a route to a Gateway on the given listeners, replacing the previous binding.
+     *
+     * @param gatewayNamespace namespace of the Gateway
+     * @param gatewayName name of the Gateway
+     * @param listenerNames listeners of the Gateway that accepted the route
+     * @param routeNamespace namespace of the route
+     * @param routeName name of the route
+     */
+    public void bindRouteToGateway(final String gatewayNamespace, final String gatewayName,
+                                   final Set<String> listenerNames,
+                                   final String routeNamespace, final String routeName) {
+        String gwKey = gatewayKey(gatewayNamespace, gatewayName);
+        String rKey = routeKey(routeNamespace, routeName);
+        GATEWAY_ROUTE_MAP.computeIfAbsent(gwKey, k -> Maps.newConcurrentMap())
+                .compute(rKey, (k, listeners) -> {
+                    Set<String> merged = Objects.isNull(listeners) ? Sets.newConcurrentHashSet() : listeners;
+                    merged.addAll(listenerNames);
+                    return merged;
+                });
+    }
+
+    /**
+     * Route keys attached through any listener, null if none.
+     *
+     * @param gatewayNamespace namespace of the Gateway
+     * @param gatewayName name of the Gateway
+     * @return route keys ("namespace/name") attached to the Gateway, null if none
+     */
+    public Set<String> getRoutesByGateway(final String gatewayNamespace, final String gatewayName) {
+        Map<String, Set<String>> routes = GATEWAY_ROUTE_MAP.get(gatewayKey(gatewayNamespace, gatewayName));
+        return Objects.isNull(routes) || routes.isEmpty() ? null : Set.copyOf(routes.keySet());
+    }
+
+    /**
+     * Route keys attached through one listener; its size is the listener's attachedRoutes.
+     *
+     * @param gatewayNamespace namespace of the Gateway
+     * @param gatewayName name of the Gateway
+     * @param listenerName name of the listener
+     * @return route keys ("namespace/name") attached through that listener, empty if none
+     */
+    public Set<String> getRoutesByListener(final String gatewayNamespace, final String gatewayName,
+                                           final String listenerName) {
+        Map<String, Set<String>> routes = GATEWAY_ROUTE_MAP.get(gatewayKey(gatewayNamespace, gatewayName));
+        if (Objects.isNull(routes)) {
+            return Set.of();
+        }
+        Set<String> attached = new HashSet<>();
+        routes.forEach((routeKey, listeners) -> {
+            if (listeners.contains(listenerName)) {
+                attached.add(routeKey);
+            }
+        });
+        return attached;
+    }
+
+    /**
+     * Gateway keys the route is bound to via multiple parentRefs, null if none.
+     *
+     * @param routeNamespace namespace of the route
+     * @param routeName name of the route
+     * @return gateway keys ("namespace/name") the route is bound to, null if none
+     */
+    public Set<String> getGatewaysForRoute(final String routeNamespace, final String routeName) {
+        String rKey = routeKey(routeNamespace, routeName);
+        Set<String> gateways = new HashSet<>();
+        GATEWAY_ROUTE_MAP.forEach((gwKey, routes) -> {
+            if (routes.containsKey(rKey)) {
+                gateways.add(gwKey);
+            }
+        });
+        return gateways.isEmpty() ? null : gateways;
+    }
+
+    public Set<String> removeRoutesByGateway(final String gatewayNamespace, final String gatewayName) {
+        String gwKey = gatewayKey(gatewayNamespace, gatewayName);
+        Map<String, Set<String>> routes = GATEWAY_ROUTE_MAP.remove(gwKey);
+        return Objects.isNull(routes) || routes.isEmpty() ? null : Set.copyOf(routes.keySet());
+    }
+
+    public void removeRouteGatewayBinding(final String routeNamespace, final String routeName) {
+        String rKey = routeKey(routeNamespace, routeName);
+        GATEWAY_ROUTE_MAP.forEach((gwKey, routes) -> routes.remove(rKey));
+    }
+
+    /**
+     * Clear all cached data. Used for testing.
+     */
+    public void clear() {
+        ROUTE_SELECTOR_MAP.clear();
+        GATEWAY_ROUTE_MAP.clear();
+    }
+
+    private String routeKey(final String namespace, final String name) {
+        return namespace + "/" + name;
+    }
+
+    private String routeKey(final String namespace, final String name, final String pluginName) {
+        return String.format("%s/%s-%s", namespace, name, pluginName);
+    }
+
+    private String gatewayKey(final String namespace, final String name) {
+        return namespace + "/" + name;
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/K8sCacheReadiness.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/K8sCacheReadiness.java
new file mode 100644
index 0000000000..44f22f1d96
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/cache/K8sCacheReadiness.java
@@ -0,0 +1,85 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.cache;
+
+import io.kubernetes.client.extended.workqueue.WorkQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+
+import java.util.Collection;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Readiness over informer initial-sync AND drained work queues: hasSynced() only means the
+ * LIST finished, while objects may still wait in the queues to reach {@code BaseDataCache}
+ * (minutes on large clusters). WorkQueue#length() misses items a worker already popped, so
+ * emptiness must hold on consecutive polls. Readiness latches: the local cache keeps serving
+ * the last known state during API outages, so flapping would only churn endpoints.
+ */
+public final class K8sCacheReadiness {
+
+    /** Consecutive polls observing every queue empty before the drain is trusted. */
+    private static final int REQUIRED_DRAINED_OBSERVATIONS = 2;
+
+    private final List<SharedIndexInformer<?>> informers;
+
+    private final List<WorkQueue<?>> workQueues;
+
+    private int drainedStreak;
+
+    private volatile boolean ready;
+
+    public K8sCacheReadiness(final Collection<SharedIndexInformer<?>> informers,
+                             final Collection<WorkQueue<?>> workQueues) {
+        if (Objects.isNull(informers) || informers.isEmpty()) {
+            throw new IllegalArgumentException("At least one informer is required");
+        }
+        this.informers = List.copyOf(informers);
+        this.workQueues = Objects.isNull(workQueues) ? List.of() : List.copyOf(workQueues);
+    }
+
+    public boolean isReady() {
+        if (ready) {
+            return true;
+        }
+        if (informersSynced() && workQueues.stream().allMatch(queue -> queue.length() == 0)) {
+            drainedStreak++;
+            ready = drainedStreak >= REQUIRED_DRAINED_OBSERVATIONS;
+        } else {
+            drainedStreak = 0;
+        }
+        return ready;
+    }
+
+    public long pendingInformers() {
+        return informers.stream().filter(informer -> !informer.hasSynced()).count();
+    }
+
+    /**
+     * Queued items across the controller work queues.
+     *
+     * @return total number of queued items
+     */
+    public long pendingWorkItems() {
+        return workQueues.stream().mapToInt(WorkQueue::length).sum();
+    }
+
+    private boolean informersSynced() {
+        return informers.stream().allMatch(SharedIndexInformer::hasSynced);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java
new file mode 100644
index 0000000000..d76530f33d
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiConstants.java
@@ -0,0 +1,186 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+
+import java.util.Objects;
+
+public final class GatewayApiConstants {
+
+    public static final String GATEWAY_API_GROUP = "gateway.networking.k8s.io";
+
+    public static final String GATEWAY_API_VERSION = "v1";
+
+    public static final String GATEWAY_KIND = "Gateway";
+
+    public static final String HTTP_ROUTE_KIND = "HTTPRoute";
+
+    public static final String SERVICE_KIND = "Service";
+
+    /** The core (legacy) API group, named by the empty string; group of Service. */
+    public static final String CORE_API_GROUP = "";
+
+    /** ResolvedRefs=False reason: a backendRef's Service has no ready endpoints. */
+    public static final String REASON_BACKEND_NOT_FOUND = "BackendNotFound";
+
+    /** ResolvedRefs=False / Accepted=False reason: a cross-namespace reference (parentRef or backendRef) is not permitted by a ReferenceGrant or listener policy. */
+    public static final String REASON_REF_NOT_PERMITTED = "RefNotPermitted";
+
+    /** ResolvedRefs=False reason: a backendRef's kind is not Service, the only supported kind. */
+    public static final String REASON_INVALID_KIND = "InvalidKind";
+
+    /** Condition type Accepted. */
+    public static final String CONDITION_ACCEPTED = "Accepted";
+
+    /** Condition type ResolvedRefs. */
+    public static final String CONDITION_RESOLVED_REFS = "ResolvedRefs";
+
+    /** Condition type Programmed. */
+    public static final String CONDITION_PROGRAMMED = "Programmed";
+
+    /** Accepted=False reason: parentRef does not resolve to an existing Gateway. */
+    public static final String REASON_NO_MATCHING_PARENT = "NoMatchingParent";
+
+    /** Accepted=False reason: no listener hostname intersects the route hostnames. */
+    public static final String REASON_NO_MATCHING_LISTENER_HOSTNAME = "NoMatchingListenerHostname";
+
+    /** Condition reason: a spec value (filter, sectionName, ...) is unsupported. */
+    public static final String REASON_UNSUPPORTED_VALUE = "UnsupportedValue";
+
+    /** Listener Accepted=False reason: protocol is not supported (only HTTP is served). */
+    public static final String REASON_UNSUPPORTED_PROTOCOL = "UnsupportedProtocol";
+
+    /** Listener Accepted=False reason: port is not served by this gateway. */
+    public static final String REASON_PORT_UNAVAILABLE = "PortUnavailable";
+
+    /** Condition reason: programmed into the data plane. */
+    public static final String REASON_PROGRAMMED = "Programmed";
+
+    /** Gateway Programmed=False reason: no listener with a supported protocol and port. */
+    public static final String REASON_LISTENERS_NOT_VALID = "ListenersNotValid";
+
+    /** Gateway Accepted=False reason: the Gateway is semantically invalid for this controller. */
+    public static final String REASON_INVALID = "Invalid";
+
+    /** The only listener protocol this controller serves. */
+    public static final String PROTOCOL_HTTP = "HTTP";
+
+    public static final String SHENYU_CONTROLLER_NAME = "gateway.shenyu.apache.org/shenyu-controller";
+
+    /** Wording shared by every Accepted=True message this controller writes; used to recognize ShenYu's own status payload on resources whose conditions carry no controllerName. */
+    public static final String ACCEPTED_BY_SHENYU_MESSAGE = "accepted by the ShenYu controller";
+
+    /** Spec-defined finalizer holding a GatewayClass alive while Gateways still use it. */
+    public static final String GATEWAY_CLASS_FINALIZER = "gateway-exists-finalizer.gateway.networking.k8s.io";
+
+    /** Condition type SupportedVersion: whether the installed CRD bundle version is supported. */
+    public static final String CONDITION_SUPPORTED_VERSION = "SupportedVersion";
+
+    /** SupportedVersion=True reason. */
+    public static final String REASON_SUPPORTED_VERSION = "SupportedVersion";
+
+    /** SupportedVersion=False reason. */
+    public static final String REASON_UNSUPPORTED_VERSION = "UnsupportedVersion";
+
+    /** GatewayClass Accepted=False reason: spec.parametersRef is set but not supported. */
+    public static final String REASON_INVALID_PARAMETERS = "InvalidParameters";
+
+    /** GatewayClass Accepted=False reason: the class is not managed by this controller. */
+    public static final String REASON_UNSUPPORTED = "Unsupported";
+
+    private GatewayApiConstants() {
+    }
+
+    /**
+     * Whether a backendRef references a core (legacy group) Service: both {@code group} and
+     * {@code kind} default to Service's core values when absent. A kind of {@code Service}
+     * with an explicit foreign group names a different resource (e.g. a custom backend
+     * implementation) and must not be resolved against core Services of the same name.
+     *
+     * @param backendRef the backendRef object
+     * @return true if the reference targets a core Service
+     */
+    public static boolean isServiceRef(final JsonObject backendRef) {
+        String kind = JsonFields.getString(backendRef, "kind");
+        if (Objects.nonNull(kind) && !SERVICE_KIND.equals(kind)) {
+            return false;
+        }
+        String group = JsonFields.getString(backendRef, "group");
+        return Objects.isNull(group) || group.isEmpty();
+    }
+
+    /**
+     * Check if a Gateway API resource has a specific condition with status "True".
+     *
+     * @param resource the dynamic kubernetes object
+     * @param conditionType the condition type to check
+     * @return true if the condition exists and is "True"
+     */
+    public static boolean isConditionTrue(final DynamicKubernetesObject resource, final String conditionType) {
+        JsonObject condition = findCondition(resource, conditionType);
+        return Objects.nonNull(condition) && "True".equals(condition.get("status").getAsString());
+    }
+
+    /**
+     * Find a resource's condition of the given type in its status.
+     *
+     * @param resource the dynamic kubernetes object
+     * @param conditionType the condition type to look for
+     * @return the condition object, or null when absent or the status is malformed
+     */
+    public static JsonObject findCondition(final DynamicKubernetesObject resource, final String conditionType) {
+        JsonObject raw = resource.getRaw();
+        JsonObject status = JsonFields.getJsonObject(raw, "status");
+        JsonArray conditions = JsonFields.getJsonArray(status, "conditions");
+        if (Objects.isNull(conditions)) {
+            return null;
+        }
+        for (JsonElement element : conditions) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            JsonObject condition = element.getAsJsonObject();
+            if (conditionType.equals(JsonFields.getString(condition, "type"))) {
+                return condition;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * Whether the resource's Accepted=True condition is ShenYu's own status payload.
+     * Gateway API conditions carry no controllerName, so on Gateways and GatewayClasses
+     * that changed ownership the only way to tell ShenYu's writing from the new
+     * controller's is the message wording this controller uses.
+     *
+     * @param resource the dynamic kubernetes object
+     * @param conditionType the condition type to check
+     * @return true if the condition is True and was written by this controller
+     */
+    public static boolean isConditionAcceptedByShenyu(final DynamicKubernetesObject resource, final String conditionType) {
+        JsonObject condition = findCondition(resource, conditionType);
+        return Objects.nonNull(condition)
+                && "True".equals(JsonFields.getString(condition, "status"))
+                && Objects.nonNull(JsonFields.getString(condition, "message"))
+                && JsonFields.getString(condition, "message").contains(ACCEPTED_BY_SHENYU_MESSAGE);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifier.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifier.java
new file mode 100644
index 0000000000..ab4ffe8fa5
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifier.java
@@ -0,0 +1,142 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.ApiResponse;
+import okhttp3.Call;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.TreeSet;
+
+/**
+ * Startup probe for the Gateway API CRDs: all four resources must serve
+ * {@code gateway.networking.k8s.io/v1} (ReferenceGrant only since v1.5.0) or the controller
+ * fails fast — missing CRDs make informers 404 forever and silently degrade. Also reads
+ * each CRD's bundle-version annotation for the SupportedVersion condition; an unreadable,
+ * unannotated or unrecognized version only degrades that condition, never startup.
+ */
+public final class GatewayApiCrdVerifier {
+
+    /** Annotation stamped on every Gateway API CRD by the official release bundles. */
+    public static final String BUNDLE_VERSION_ANNOTATION = "gateway.networking.k8s.io/bundle-version";
+
+    private static final Logger LOG = LoggerFactory.getLogger(GatewayApiCrdVerifier.class);
+
+    private static final Set<String> REQUIRED_RESOURCES = Set.of(
+            "gatewayclasses", "gateways", "httproutes", "referencegrants");
+
+    private static final String CRD_API_PATH = "/apis/apiextensions.k8s.io/v1/customresourcedefinitions/";
+
+    private GatewayApiCrdVerifier() {
+    }
+
+    /**
+     * Fails fast on missing resources; returns the detected bundle versions.
+     *
+     * @param apiClient the Kubernetes API client
+     * @return the detected CRD bundle versions
+     * @throws IllegalStateException when any required resource is missing
+     */
+    public static GatewayApiCrdVersions verify(final ApiClient apiClient) {
+        String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION;
+        final JsonObject resourceList;
+        try {
+            resourceList = fetchJson(apiClient, path);
+        } catch (ApiException e) {
+            throw new IllegalStateException("Gateway API is not available at " + path + " (HTTP " + e.getCode()
+                    + "). Install the Gateway API standard channel CRDs (>= v1.5.0) or disable shenyu.k8s.mode=gateway-api.", e);
+        }
+        Set<String> served = new HashSet<>();
+        JsonArray resources = JsonFields.getJsonArray(resourceList, "resources");
+        if (Objects.nonNull(resources)) {
+            for (JsonElement element : resources) {
+                if (element.isJsonObject()) {
+                    String name = JsonFields.getString(element.getAsJsonObject(), "name");
+                    if (Objects.nonNull(name) && !name.contains("/")) {
+                        served.add(name);
+                    }
+                }
+            }
+        }
+        Set<String> missing = new HashSet<>(REQUIRED_RESOURCES);
+        missing.removeAll(served);
+        if (!missing.isEmpty()) {
+            throw new IllegalStateException("Gateway API CRDs " + missing + " are not served at "
+                    + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+                    + ". Install the standard channel CRDs (>= v1.5.0; referencegrants serve v1 only since then)"
+                    + " or disable shenyu.k8s.mode=gateway-api.");
+        }
+        LOG.info("Gateway API CRDs verified: all required resources are served at {}/{}",
+                GatewayApiConstants.GATEWAY_API_GROUP, GatewayApiConstants.GATEWAY_API_VERSION);
+        return detectBundleVersions(apiClient);
+    }
+
+    private static JsonObject fetchJson(final ApiClient apiClient, final String path) throws ApiException {
+        Map<String, String> headerParams = new HashMap<>();
+        headerParams.put("Accept", "application/json");
+        String[] authNames = apiClient.getAuthentications().keySet().toArray(new String[0]);
+        // buildCall dereferences cookieParams unconditionally, so it must be non-null
+        Call call = apiClient.buildCall(path, "GET", null, null, null, headerParams,
+                new HashMap<>(), null, authNames, null);
+        ApiResponse<JsonObject> response = apiClient.execute(call, JsonObject.class);
+        return response.getData();
+    }
+
+    /**
+     * Read the bundle-version annotation off every required CRD. Fetch failures degrade to
+     * an unsupported verdict instead of failing startup.
+     */
+    private static GatewayApiCrdVersions detectBundleVersions(final ApiClient apiClient) {
+        Set<String> detected = new TreeSet<>();
+        boolean supported = true;
+        for (String resource : new TreeSet<>(REQUIRED_RESOURCES)) {
+            String crdName = resource + "." + GatewayApiConstants.GATEWAY_API_GROUP;
+            String version = null;
+            try {
+                JsonObject crd = fetchJson(apiClient, CRD_API_PATH + crdName);
+                JsonObject metadata = JsonFields.getJsonObject(crd, "metadata");
+                JsonObject annotations = JsonFields.getJsonObject(metadata, "annotations");
+                version = JsonFields.getString(annotations, BUNDLE_VERSION_ANNOTATION);
+            } catch (ApiException e) {
+                LOG.warn("Could not read CRD {} to detect its bundle version (HTTP {}); marking CRD versions unsupported",
+                        crdName, e.getCode());
+            }
+            int[] majorMinor = GatewayApiCrdVersions.majorMinor(version);
+            if (Objects.isNull(version) || Objects.isNull(majorMinor) || !GatewayApiCrdVersions.atLeastMinSupported(majorMinor)) {
+                supported = false;
+            }
+            if (Objects.nonNull(version)) {
+                detected.add(version);
+            }
+        }
+        LOG.info("Gateway API CRD bundle versions detected: {} (supported: {})", detected, supported);
+        return new GatewayApiCrdVersions(new ArrayList<>(detected), supported);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersions.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersions.java
new file mode 100644
index 0000000000..0661570320
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersions.java
@@ -0,0 +1,102 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * CRD bundle versions detected at startup with the SupportedVersion verdict: a missing
+ * annotation or unrecognized version means unsupported; the controller still serves
+ * best-effort because it only relies on the v1 GA surface.
+ */
+public final class GatewayApiCrdVersions {
+
+    /** Oldest bundle version whose standard-channel CRDs serve ReferenceGrant at v1. */
+    private static final int[] MIN_SUPPORTED = {1, 5};
+
+    private final List<String> detectedVersions;
+
+    private final boolean supported;
+
+    public GatewayApiCrdVersions(final List<String> detectedVersions, final boolean supported) {
+        this.detectedVersions = detectedVersions.stream().distinct().sorted().toList();
+        this.supported = supported;
+    }
+
+    /**
+     * Whether every required CRD carries a recognized bundle version at or above the minimum.
+     *
+     * @return true when the install is fully supported
+     */
+    public boolean isSupported() {
+        return supported;
+    }
+
+    /**
+     * Distinct detected bundle-version annotation values, sorted.
+     *
+     * @return the detected versions, possibly empty when CRDs carry no annotation
+     */
+    public List<String> detectedVersions() {
+        return detectedVersions;
+    }
+
+    /**
+     * Parse {@code v1.5.1} into (major, minor); null when not numeric.
+     *
+     * @param version CRD bundle version string, e.g. {@code v1.5.1}
+     * @return {major, minor} pair, or null when not numeric
+     */
+    public static int[] majorMinor(final String version) {
+        if (Objects.isNull(version)) {
+            return null;
+        }
+        String trimmed = version.startsWith("v") ? version.substring(1) : version;
+        String[] parts = trimmed.split("\\.");
+        if (parts.length < 2) {
+            return null;
+        }
+        try {
+            return new int[]{Integer.parseInt(parts[0]), Integer.parseInt(parts[1])};
+        } catch (NumberFormatException ex) {
+            return null;
+        }
+    }
+
+    /**
+     * Whether the parsed pair is at or above the minimum supported version.
+     *
+     * @param majorMinor parsed {major, minor} pair
+     * @return true when at or above the minimum supported version
+     */
+    public static boolean atLeastMinSupported(final int[] majorMinor) {
+        return Objects.nonNull(majorMinor)
+                && (majorMinor[0] > MIN_SUPPORTED[0]
+                || majorMinor[0] == MIN_SUPPORTED[0] && majorMinor[1] >= MIN_SUPPORTED[1]);
+    }
+
+    /**
+     * Human-readable form for status messages, e.g. "v1.5.1, v1.6.0" or "none annotated".
+     *
+     * @return the detected versions joined for display
+     */
+    public String describeDetected() {
+        return detectedVersions.isEmpty() ? "none annotated" : String.join(", ", detectedVersions);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/JsonFields.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/JsonFields.java
new file mode 100644
index 0000000000..2306bed6df
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/JsonFields.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+
+import java.util.Objects;
+
+/**
+ * Null-safe field accessors for dynamic Gateway API json, where fields may be absent,
+ * null or mistyped (foreign status is not schema-validated); null returns degrade a
+ * reconcile to a no-op instead of an infinite retry loop.
+ */
+public final class JsonFields {
+
+    private JsonFields() {
+    }
+
+    public static String getString(final JsonObject obj, final String field) {
+        if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()
+                || !obj.get(field).isJsonPrimitive()) {
+            return null;
+        }
+        return obj.get(field).getAsString();
+    }
+
+    /**
+     * Read an optional numeric field as Long.
+     *
+     * @param obj the object to read from
+     * @param field the field name
+     * @return the number, or null when absent or not numeric
+     */
+    public static Long getLong(final JsonObject obj, final String field) {
+        if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()
+                || !obj.get(field).isJsonPrimitive()) {
+            return null;
+        }
+        try {
+            return obj.get(field).getAsLong();
+        } catch (NumberFormatException | UnsupportedOperationException ex) {
+            return null;
+        }
+    }
+
+    public static JsonObject getJsonObject(final JsonObject obj, final String field) {
+        if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()
+                || !obj.get(field).isJsonObject()) {
+            return null;
+        }
+        return obj.getAsJsonObject(field);
+    }
+
+    public static JsonArray getJsonArray(final JsonObject obj, final String field) {
+        if (Objects.isNull(obj) || !obj.has(field) || obj.get(field).isJsonNull()
+                || !obj.get(field).isJsonArray()) {
+            return null;
+        }
+        return obj.getAsJsonArray(field);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ListenerSupport.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ListenerSupport.java
new file mode 100644
index 0000000000..8b7be0a45e
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ListenerSupport.java
@@ -0,0 +1,244 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Read-only helpers for evaluating Gateway {@code spec.listeners}: which listeners an
+ * HTTPRoute may attach to (namespace policy, kind policy, protocol support) and the
+ * route/listener hostname intersection mandated by the Gateway API attachment rules.
+ */
+public final class ListenerSupport {
+
+    private ListenerSupport() {
+    }
+
+    /**
+     * Listeners matching the optional sectionName and parentRef port; both selectors must match.
+     *
+     * @param gatewayRaw raw Gateway json
+     * @param sectionName optional sectionName
+     * @param parentPort optional parentRef port; null skips the port check
+     * @return matching listeners; empty when sectionName matches no listener
+     */
+    public static List<JsonObject> selectListeners(final JsonObject gatewayRaw, final String sectionName,
+                                                   final Long parentPort) {
+        JsonObject spec = JsonFields.getJsonObject(gatewayRaw, "spec");
+        JsonArray listeners = JsonFields.getJsonArray(spec, "listeners");
+        List<JsonObject> result = new ArrayList<>();
+        if (Objects.isNull(listeners)) {
+            return result;
+        }
+        for (JsonElement element : listeners) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            JsonObject listener = element.getAsJsonObject();
+            boolean nameMatches = Objects.isNull(sectionName) || sectionName.equals(nameOf(listener));
+            boolean portMatches = Objects.isNull(parentPort) || parentPort.equals(portOf(listener));
+            if (nameMatches && portMatches) {
+                result.add(listener);
+            }
+        }
+        return result;
+    }
+
+    /**
+     * Listeners selected by sectionName only.
+     *
+     * @param gatewayRaw raw Gateway json
+     * @param sectionName optional sectionName
+     * @return matching listeners; empty when sectionName matches no listener
+     */
+    public static List<JsonObject> selectListeners(final JsonObject gatewayRaw, final String sectionName) {
+        return selectListeners(gatewayRaw, sectionName, null);
+    }
+
+    /**
+     * True when the listener's port equals the served data-plane port; a portless listener cannot be confirmed served.
+     *
+     * @param listener the listener object
+     * @param servedPort the port the data plane listens on
+     * @return true if the listener's port is served
+     */
+    public static boolean servesPort(final JsonObject listener, final long servedPort) {
+        Long port = portOf(listener);
+        return Objects.nonNull(port) && port == servedPort;
+    }
+
+    public static String nameOf(final JsonObject listener) {
+        return JsonFields.getString(listener, "name");
+    }
+
+    /**
+     * Listener protocol; defaults to HTTP per the spec.
+     *
+     * @param listener the listener object
+     * @return the protocol, never null
+     */
+    public static String protocolOf(final JsonObject listener) {
+        String protocol = JsonFields.getString(listener, "protocol");
+        return Objects.isNull(protocol) ? GatewayApiConstants.PROTOCOL_HTTP : protocol;
+    }
+
+    public static String hostnameOf(final JsonObject listener) {
+        return JsonFields.getString(listener, "hostname");
+    }
+
+    /**
+     * Listener port; null when absent (foreign status is not schema-guaranteed).
+     *
+     * @param listener the listener object
+     * @return the port, or null
+     */
+    public static Long portOf(final JsonObject listener) {
+        return JsonFields.getLong(listener, "port");
+    }
+
+    /**
+     * Only plain HTTP is supported.
+     *
+     * @param listener the listener object
+     * @return true if the listener speaks plain HTTP
+     */
+    public static boolean isSupportedProtocol(final JsonObject listener) {
+        return GatewayApiConstants.PROTOCOL_HTTP.equals(protocolOf(listener));
+    }
+
+    /**
+     * Spec default is Same, from=All allows all; from=Selector is unimplemented and denies (widening would break isolation).
+     *
+     * @param listener the listener object
+     * @param routeNamespace namespace of the attaching HTTPRoute
+     * @param gatewayNamespace namespace of the Gateway
+     * @return true if the namespace policy permits the attachment
+     */
+    public static boolean allowsNamespace(final JsonObject listener, final String routeNamespace, final String gatewayNamespace) {
+        String from = fromOf(listener);
+        if (Objects.isNull(from) || "Same".equals(from)) {
+            return Objects.equals(routeNamespace, gatewayNamespace);
+        }
+        return "All".equals(from);
+    }
+
+    /**
+     * from=Selector is unsupported; distinguishable so status reports UnsupportedValue, not a permission denial.
+     *
+     * @param listener the listener json object
+     * @return true when allowedRoutes.from is the unsupported Selector value
+     */
+    public static boolean usesUnsupportedFrom(final JsonObject listener) {
+        return "Selector".equals(fromOf(listener));
+    }
+
+    private static String fromOf(final JsonObject listener) {
+        JsonObject allowedRoutes = JsonFields.getJsonObject(listener, "allowedRoutes");
+        JsonObject namespaces = JsonFields.getJsonObject(allowedRoutes, "namespaces");
+        return JsonFields.getString(namespaces, "from");
+    }
+
+    /**
+     * Absent kinds means all protocol-matching kinds, i.e. HTTPRoute for HTTP.
+     *
+     * @param listener the listener object
+     * @return true if HTTPRoute is permitted
+     */
+    public static boolean allowsKind(final JsonObject listener) {
+        JsonObject allowedRoutes = JsonFields.getJsonObject(listener, "allowedRoutes");
+        JsonArray kinds = JsonFields.getJsonArray(allowedRoutes, "kinds");
+        if (Objects.isNull(kinds) || kinds.size() == 0) {
+            return true;
+        }
+        for (JsonElement element : kinds) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            JsonObject kind = element.getAsJsonObject();
+            String group = JsonFields.getString(kind, "group");
+            boolean groupMatches = Objects.isNull(group) || GatewayApiConstants.GATEWAY_API_GROUP.equals(group);
+            if (groupMatches && GatewayApiConstants.HTTP_ROUTE_KIND.equals(JsonFields.getString(kind, "kind"))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Route × listener hostname intersection; a null listener hostname imposes no restriction.
+     *
+     * @param listenerHostname listener hostname, may be null
+     * @param routeHostnames hostnames from the HTTPRoute spec, empty means "any host"
+     * @return effective hostnames, or null when the two sides have no overlap
+     */
+    public static List<String> intersectHostnames(final String listenerHostname, final List<String> routeHostnames) {
+        if (Objects.isNull(listenerHostname)) {
+            return new ArrayList<>(routeHostnames);
+        }
+        if (routeHostnames.isEmpty()) {
+            return List.of(listenerHostname);
+        }
+        Set<String> overlaps = new LinkedHashSet<>();
+        for (String routeHostname : routeHostnames) {
+            String overlap = overlap(routeHostname, listenerHostname);
+            if (Objects.nonNull(overlap)) {
+                overlaps.add(overlap);
+            }
+        }
+        return overlaps.isEmpty() ? null : new ArrayList<>(overlaps);
+    }
+
+    /** The more specific of two overlapping hostnames; {@code *.example.com} matches one or more labels, never the bare domain. */
+    private static String overlap(final String routeHostname, final String listenerHostname) {
+        if (routeHostname.equals(listenerHostname)) {
+            return routeHostname;
+        }
+        boolean routeWildcard = routeHostname.startsWith("*.");
+        boolean listenerWildcard = listenerHostname.startsWith("*.");
+        if (!routeWildcard && !listenerWildcard) {
+            return null;
+        }
+        if (routeWildcard && listenerWildcard) {
+            String routeSuffix = routeHostname.substring(2);
+            String listenerSuffix = listenerHostname.substring(2);
+            if (listenerSuffix.endsWith("." + routeSuffix)) {
+                return listenerHostname;
+            }
+            if (routeSuffix.endsWith("." + listenerSuffix)) {
+                return routeHostname;
+            }
+            return null;
+        }
+        if (routeWildcard) {
+            return wildcardCovers(routeHostname, listenerHostname) ? listenerHostname : null;
+        }
+        return wildcardCovers(listenerHostname, routeHostname) ? routeHostname : null;
+    }
+
+    private static boolean wildcardCovers(final String wildcard, final String hostname) {
+        return hostname.endsWith("." + wildcard.substring(2));
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ReferenceGrants.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ReferenceGrants.java
new file mode 100644
index 0000000000..576a14d766
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ReferenceGrants.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * ReferenceGrant (GEP-709) matching for cross-namespace references: a grant lives in
+ * the namespace of the referenced resource and allows an HTTPRoute from one namespace
+ * to reference a resource of a given group/kind (optionally restricted by name).
+ */
+public final class ReferenceGrants {
+
+    private ReferenceGrants() {
+    }
+
+    /**
+     * Whether a grant in grantNamespace (where referenced resources' grants live) allows the reference; to.name restricts to one resource.
+     *
+     * @param grantLister lister for ReferenceGrant resources
+     * @param grantNamespace namespace of the referenced resource, where grants live
+     * @param fromNamespace namespace of the referencing HTTPRoute
+     * @param toGroup API group of the referenced resource
+     * @param toKind kind of the referenced resource
+     * @param toName name of the referenced resource, null for unrestricted grants
+     * @return true if a matching grant exists
+     */
+    public static boolean isGranted(final Lister<DynamicKubernetesObject> grantLister, final String grantNamespace,
+                                    final String fromNamespace, final String toGroup, final String toKind,
+                                    final String toName) {
+        List<DynamicKubernetesObject> grants = grantLister.namespace(grantNamespace).list();
+        if (CollectionUtils.isEmpty(grants)) {
+            return false;
+        }
+        for (DynamicKubernetesObject grant : grants) {
+            if (matches(grant, fromNamespace, toGroup, toKind, toName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Requires a matching spec.from (HTTPRoute@fromNamespace) AND spec.to (group/kind, optionally name). */
+    private static boolean matches(final DynamicKubernetesObject grant, final String fromNamespace,
+                                   final String toGroup, final String toKind, final String toName) {
+        JsonObject spec = grant.getRaw().getAsJsonObject("spec");
+        if (Objects.isNull(spec)) {
+            return false;
+        }
+        boolean fromMatched = false;
+        JsonArray from = spec.has("from") ? spec.getAsJsonArray("from") : null;
+        if (Objects.nonNull(from)) {
+            for (JsonElement el : from) {
+                JsonObject fromRule = el.getAsJsonObject();
+                if (GatewayApiConstants.GATEWAY_API_GROUP.equals(JsonFields.getString(fromRule, "group"))
+                        && GatewayApiConstants.HTTP_ROUTE_KIND.equals(JsonFields.getString(fromRule, "kind"))
+                        && fromNamespace.equals(JsonFields.getString(fromRule, "namespace"))) {
+                    fromMatched = true;
+                    break;
+                }
+            }
+        }
+        if (!fromMatched) {
+            return false;
+        }
+        JsonArray to = spec.has("to") ? spec.getAsJsonArray("to") : null;
+        if (Objects.isNull(to)) {
+            return false;
+        }
+        for (JsonElement el : to) {
+            JsonObject toRule = el.getAsJsonObject();
+            if (!toGroup.equals(JsonFields.getString(toRule, "group"))
+                    || !toKind.equals(JsonFields.getString(toRule, "kind"))) {
+                continue;
+            }
+            String grantedName = JsonFields.getString(toRule, "name");
+            if (Objects.isNull(grantedName) || grantedName.equals(toName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ShenyuMemoryConfig.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ShenyuMemoryConfig.java
index 96011398de..ad48562e3f 100644
--- a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ShenyuMemoryConfig.java
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/ShenyuMemoryConfig.java
@@ -33,6 +33,15 @@ public class ShenyuMemoryConfig {
 
     private List<SslCrtAndKeyStream> tlsConfigList;
 
+    /** Whether every backendRef resolved to at least one ready upstream; drives ResolvedRefs. */
+    private boolean allBackendsResolved = true;
+
+    /** Reason for ResolvedRefs=False (BackendNotFound, RefNotPermitted or InvalidKind). */
+    private String unresolvedReason;
+
+    /** Unsupported filters must surface as Accepted=False/UnsupportedValue with the rule unprogrammed. */
+    private boolean hasUnsupportedFilters;
+
     /**
      * ShenyuMemoryConfig Constructor.
      */
@@ -92,4 +101,28 @@ public class ShenyuMemoryConfig {
     public void setTlsConfigList(final List<SslCrtAndKeyStream> tlsConfigList) {
         this.tlsConfigList = tlsConfigList;
     }
+
+    public boolean isAllBackendsResolved() {
+        return allBackendsResolved;
+    }
+
+    public void setAllBackendsResolved(final boolean allBackendsResolved) {
+        this.allBackendsResolved = allBackendsResolved;
+    }
+
+    public String getUnresolvedReason() {
+        return unresolvedReason;
+    }
+
+    public void setUnresolvedReason(final String unresolvedReason) {
+        this.unresolvedReason = unresolvedReason;
+    }
+
+    public boolean isHasUnsupportedFilters() {
+        return hasUnsupportedFilters;
+    }
+
+    public void setHasUnsupportedFilters(final boolean hasUnsupportedFilters) {
+        this.hasUnsupportedFilters = hasUnsupportedFilters;
+    }
 }
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/StatusMergePatch.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/StatusMergePatch.java
new file mode 100644
index 0000000000..eb27b73868
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/common/StatusMergePatch.java
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import com.google.gson.JsonObject;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import okhttp3.Call;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Merge-patch through the {@link ApiClient}'s request pipeline: a raw okhttp call would
+ * miss per-request header auth (static kubeconfig tokens) and only work for client-bound auth.
+ */
+public final class StatusMergePatch {
+
+    private static final String MERGE_PATCH_CONTENT_TYPE = "application/merge-patch+json";
+
+    private StatusMergePatch() {
+    }
+
+    /**
+     * Merge-patch the resource at the given path.
+     *
+     * @param apiClient the Kubernetes API client providing transport and authentication
+     * @param path the /status subresource path
+     * @param body the patch body
+     * @throws ApiException when the API server rejects the patch
+     */
+    public static void patch(final ApiClient apiClient, final String path, final JsonObject body) throws ApiException {
+        Map<String, String> headerParams = new HashMap<>();
+        headerParams.put("Accept", "application/json");
+        headerParams.put("Content-Type", MERGE_PATCH_CONTENT_TYPE);
+        String[] authNames = apiClient.getAuthentications().keySet().toArray(new String[0]);
+        // buildCall dereferences cookieParams unconditionally, so it must be non-null
+        Call call = apiClient.buildCall(path, "PATCH", null, null, body, headerParams,
+                new HashMap<>(), null, authNames, null);
+        apiClient.execute(call);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/parser/HttpRouteParser.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/parser/HttpRouteParser.java
new file mode 100644
index 0000000000..c2e3a80e63
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/parser/HttpRouteParser.java
@@ -0,0 +1,722 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.parser;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.custom.IntOrString;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.models.CoreV1EndpointPort;
+import io.kubernetes.client.openapi.models.V1EndpointAddress;
+import io.kubernetes.client.openapi.models.V1EndpointSubset;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServicePort;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.rule.impl.DivideRuleHandle;
+import org.apache.shenyu.common.dto.convert.selector.DivideUpstream;
+import org.apache.shenyu.common.enums.LoadBalanceEnum;
+import org.apache.shenyu.common.enums.MatchModeEnum;
+import org.apache.shenyu.common.enums.OperatorEnum;
+import org.apache.shenyu.common.enums.ParamTypeEnum;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.common.enums.SelectorTypeEnum;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.IngressConfiguration;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.apache.shenyu.k8s.common.ReferenceGrants;
+import org.apache.shenyu.k8s.common.ShenyuMemoryConfig;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+import java.util.UUID;
+import java.util.regex.Pattern;
+
+/**
+ * Parses an HTTPRoute into ShenYu divide selectors/rules; pure, no cache or data-plane
+ * side effects. Match precedence follows the spec (hostname, path, method presence, header
+ * count, query count, first rule) and is encoded in the selector sort; because the data
+ * plane groups matches by condition count BEFORE comparing sort
+ * ({@code AbstractShenyuPlugin#manyMatchSelector}), every selector's condition list is
+ * padded to a fixed length with always-true duplicates so the sort alone decides. The
+ * traffic share of invalid weighted backendRefs goes to a loopback fail-target upstream:
+ * the connection is refused instantly, so the request fails with a 500 instead of
+ * re-flowing onto healthy backends.
+ */
+public class HttpRouteParser {
+
+    private static final Logger LOG = LoggerFactory.getLogger(HttpRouteParser.class);
+
+    private static final String ID_PREFIX = "gwapi-";
+
+    private static final String NO_HOSTNAME_PLACEHOLDER = "_";
+
+    /** Base of the packed precedence sort (hostname 3b, path 11b, method 1b, headers 4b, query 4b, rule index 4b); lower sort wins. */
+    private static final int SORT_PRECEDENCE_BASE = 1 << 28;
+
+    /** Condition-list length selectors are padded to, defeating the data plane's count-first tie-breaking. */
+    private static final int CONDITION_COUNT_FLOOR = 16;
+
+    /** Loopback port 1 has no listener in the pod: connections are refused instantly, failing the invalid share with a 500. */
+    private static final String FAIL_TARGET_URL = "127.0.0.1:1";
+
+    private final Lister<V1Endpoints> endpointsLister;
+
+    private final Lister<V1Service> serviceLister;
+
+    private final Lister<DynamicKubernetesObject> referenceGrantLister;
+
+    public HttpRouteParser(final Lister<V1Endpoints> endpointsLister,
+                           final Lister<V1Service> serviceLister,
+                           final Lister<DynamicKubernetesObject> referenceGrantLister) {
+        this.endpointsLister = endpointsLister;
+        this.serviceLister = serviceLister;
+        this.referenceGrantLister = referenceGrantLister;
+    }
+
+    /**
+     * Parse the HTTPRoute into a ShenYu config snapshot.
+     * @param httpRoute the route object
+     * @param hostnames effective hostnames (route × listener intersection); empty means "any host"
+     * @return the parsed config, never null
+     */
+    public ShenyuMemoryConfig parse(final DynamicKubernetesObject httpRoute, final List<String> hostnames) {
+        ShenyuMemoryConfig res = new ShenyuMemoryConfig();
+        String namespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace();
+        String routeName = httpRoute.getMetadata().getName();
+        List<IngressConfiguration> routeConfigList = new ArrayList<>();
+        res.setRouteConfigList(routeConfigList);
+
+        JsonObject spec = JsonFields.getJsonObject(httpRoute.getRaw(), "spec");
+        if (Objects.nonNull(spec)) {
+            JsonArray rules = JsonFields.getJsonArray(spec, "rules");
+            ResolveState resolveState = new ResolveState();
+            for (int ruleIndex = 0; Objects.nonNull(rules) && ruleIndex < rules.size(); ruleIndex++) {
+                if (rules.get(ruleIndex).isJsonObject()) {
+                    processRule(rules.get(ruleIndex).getAsJsonObject(), hostnames, namespace, routeName, ruleIndex,
+                            routeConfigList, resolveState);
+                }
+            }
+            res.setAllBackendsResolved(!resolveState.anyUnresolved);
+            res.setUnresolvedReason(resolveState.reason);
+            res.setHasUnsupportedFilters(resolveState.unsupportedFilters);
+        }
+        return res;
+    }
+
+    private void processRule(final JsonObject rule, final List<String> hostnames, final String namespace,
+                             final String routeName, final int ruleIndex,
+                             final List<IngressConfiguration> routeConfigList,
+                             final ResolveState resolveState) {
+        // Unsupported filters must surface as Accepted=False/UnsupportedValue, never apply partially.
+        JsonArray filters = JsonFields.getJsonArray(rule, "filters");
+        if (Objects.nonNull(filters) && !filters.isEmpty()) {
+            resolveState.unsupportedFilters = true;
+            LOG.warn("HTTPRoute {}/{} rule {} declares filters which are not supported; the rule is not programmed",
+                    namespace, routeName, ruleIndex);
+            return;
+        }
+
+        // Without backendRefs there is no ShenYu equivalent; leave requests unmatched.
+        JsonArray backendRefs = JsonFields.getJsonArray(rule, "backendRefs");
+        if (Objects.isNull(backendRefs) || backendRefs.isEmpty()) {
+            return;
+        }
+
+        BackendResolveResult result = parseBackendRefs(backendRefs, namespace, routeName);
+        if (result.unresolvedCount > 0) {
+            resolveState.anyUnresolved = true;
+            if (Objects.isNull(resolveState.reason)) {
+                resolveState.reason = result.unresolvedReason;
+            }
+            LOG.warn("HTTPRoute {}/{} rule {} has {} unresolved backendRef(s)",
+                    namespace, routeName, ruleIndex, result.unresolvedCount);
+        }
+
+        // All backends valid with weight 0: removed from rotation, nothing to program.
+        if (result.upstreams.isEmpty()) {
+            return;
+        }
+        emitRuleSelectors(rule, hostnames, namespace, routeName, ruleIndex, routeConfigList, result.upstreams);
+    }
+
+    /**
+     * Fan the rule out into selectors/rules: one selector per (hostname, match) pair, all
+     * sharing the rule's upstream list.
+     */
+    private void emitRuleSelectors(final JsonObject rule, final List<String> hostnames, final String namespace,
+                                   final String routeName, final int ruleIndex,
+                                   final List<IngressConfiguration> routeConfigList,
+                                   final List<DivideUpstream> upstreamList) {
+        // One selector per hostname: AND semantics cannot express "any of these hostnames".
+        JsonArray matches = JsonFields.getJsonArray(rule, "matches");
+        if (Objects.nonNull(matches) && !matches.isEmpty()) {
+            for (int matchIndex = 0; matchIndex < matches.size(); matchIndex++) {
+                if (!matches.get(matchIndex).isJsonObject()) {
+                    continue;
+                }
+                JsonObject match = matches.get(matchIndex).getAsJsonObject();
+                List<ConditionData> matchConditions = new ArrayList<>();
+                appendMatchConditions(matchConditions, match);
+                if (hostnames.isEmpty()) {
+                    addSelectorRule(routeConfigList, namespace, routeName, ruleIndex, null,
+                            matchIndex, selectorSort(null, match, ruleIndex), matchConditions, upstreamList);
+                } else {
+                    for (String hostname : hostnames) {
+                        addSelectorRule(routeConfigList, namespace, routeName, ruleIndex,
+                                hostname, matchIndex, selectorSort(hostname, match, ruleIndex),
+                                composeConditions(hostname, matchConditions), upstreamList);
+                    }
+                }
+            }
+        } else {
+            // Spec: a rule without matches matches everything, like PathPrefix /
+            JsonObject noMatch = new JsonObject();
+            if (hostnames.isEmpty()) {
+                addSelectorRule(routeConfigList, namespace, routeName, ruleIndex, null,
+                        0, selectorSort(null, noMatch, ruleIndex), new ArrayList<>(), upstreamList);
+            } else {
+                for (String hostname : hostnames) {
+                    addSelectorRule(routeConfigList, namespace, routeName, ruleIndex,
+                            hostname, 0, selectorSort(hostname, noMatch, ruleIndex),
+                            composeConditions(hostname, new ArrayList<>()), upstreamList);
+                }
+            }
+        }
+    }
+
+    private void addSelectorRule(final List<IngressConfiguration> routeConfigList,
+                                 final String namespace, final String routeName, final int ruleIndex,
+                                 final String hostname, final int matchIndex, final int sort,
+                                 final List<ConditionData> conditions, final List<DivideUpstream> upstreamList) {
+        // A CUSTOM_FLOW selector with no conditions never matches; match-all fills the gap.
+        if (conditions.isEmpty()) {
+            conditions.add(matchAllCondition());
+        }
+        String selectorId = deterministicSelectorId(namespace, routeName, ruleIndex, hostname, matchIndex);
+        String ruleId = deterministicRuleId(selectorId, matchIndex);
+        String hostComponent = Objects.isNull(hostname) ? "" : "-" + hostname;
+        String selectorName = routeName + "-rule-" + ruleIndex + hostComponent + "-m" + matchIndex;
+        // Only the selector is padded; the rule's trie cache would just bloat with duplicates.
+        SelectorData selectorData = buildSelectorData(selectorId, selectorName, sort,
+                padToConditionFloor(conditions), upstreamList);
+        RuleData ruleData = buildRuleData(ruleId, selectorId, selectorName, conditions);
+        routeConfigList.add(new IngressConfiguration(selectorData, List.of(ruleData), null));
+    }
+
+    /** Pad to the fixed condition count so the precedence-encoded sort alone decides (see class javadoc). */
+    private List<ConditionData> padToConditionFloor(final List<ConditionData> conditions) {
+        if (conditions.size() >= CONDITION_COUNT_FLOOR) {
+            return conditions;
+        }
+        List<ConditionData> padded = new ArrayList<>(conditions);
+        while (padded.size() < CONDITION_COUNT_FLOOR) {
+            padded.add(matchAllCondition());
+        }
+        return padded;
+    }
+
+    /** Every request path starts with '/', so this condition matches all requests. */
+    private ConditionData matchAllCondition() {
+        ConditionData condition = new ConditionData();
+        condition.setParamType(ParamTypeEnum.URI.getName());
+        condition.setOperator(OperatorEnum.STARTS_WITH.getAlias());
+        condition.setParamValue("/");
+        return condition;
+    }
+
+    private List<ConditionData> composeConditions(final String hostname,
+                                                  final List<ConditionData> matchConditions) {
+        List<ConditionData> conditions = new ArrayList<>();
+        conditions.add(buildHostnameCondition(hostname));
+        conditions.addAll(matchConditions);
+        return conditions;
+    }
+
+    /** Deterministic ID: a resync upserts instead of delete-then-create on the data plane. */
+    private String deterministicSelectorId(final String namespace, final String routeName, final int ruleIndex,
+                                           final String hostname, final int matchIndex) {
+        String hostComponent = Objects.isNull(hostname) ? NO_HOSTNAME_PLACEHOLDER : hostname;
+        String key = namespace + "/" + routeName + "/r" + ruleIndex + "/h" + hostComponent + "/m" + matchIndex;
+        return ID_PREFIX + UUID.nameUUIDFromBytes(key.getBytes(StandardCharsets.UTF_8));
+    }
+
+    /** Derive a deterministic rule ID from its parent selector ID; stays under varchar(128). */
+    private String deterministicRuleId(final String selectorId, final int matchIndex) {
+        return selectorId + "/rule-m" + matchIndex;
+    }
+
+    /** Exact hostnames use EQ; a wildcard is a multi-label suffix match, expressible only as REGEX. */
+    private ConditionData buildHostnameCondition(final String hostname) {
+        ConditionData condition = new ConditionData();
+        condition.setParamType(ParamTypeEnum.DOMAIN.getName());
+        if (hostname.startsWith("*.")) {
+            String suffix = hostname.substring(2).replace(".", "\\.");
+            condition.setOperator(OperatorEnum.REGEX.getAlias());
+            condition.setParamValue("^([^.]+\\.)+" + suffix + "$");
+        } else {
+            condition.setOperator(OperatorEnum.EQ.getAlias());
+            condition.setParamValue(hostname);
+        }
+        return condition;
+    }
+
+    private SelectorData buildSelectorData(final String selectorId, final String selectorName, final int sort,
+                                           final List<ConditionData> conditions, final List<DivideUpstream> upstreamList) {
+        return SelectorData.builder()
+                .id(selectorId)
+                .pluginId(String.valueOf(PluginEnum.DIVIDE.getCode()))
+                .pluginName(PluginEnum.DIVIDE.getName())
+                .name(selectorName)
+                .sort(sort)
+                .matchMode(MatchModeEnum.AND.getCode())
+                .type(SelectorTypeEnum.CUSTOM_FLOW.getCode())
+                .enabled(true)
+                .logged(false)
+                .continued(true)
+                .conditionList(conditions)
+                .handle(GsonUtils.getInstance().toJson(upstreamList))
+                .build();
+    }
+
+    private RuleData buildRuleData(final String ruleId, final String selectorId,
+                                   final String selectorName, final List<ConditionData> conditions) {
+        DivideRuleHandle divideRuleHandle = new DivideRuleHandle();
+        divideRuleHandle.setLoadBalance(LoadBalanceEnum.RANDOM.getName());
+        divideRuleHandle.setRetry(3);
+        divideRuleHandle.setTimeout(3000L);
+
+        return RuleData.builder()
+                .id(ruleId)
+                .selectorId(selectorId)
+                .name(selectorName)
+                .pluginName(PluginEnum.DIVIDE.getName())
+                .sort(1)
+                .matchMode(MatchModeEnum.AND.getCode())
+                .conditionDataList(conditions)
+                .handle(GsonUtils.getInstance().toJson(divideRuleHandle))
+                .loged(false)
+                .enabled(true)
+                .build();
+    }
+
+    /** Resolve backendRefs to upstream addresses; non-Service kinds, unauthorized cross-namespace refs and missing Endpoints stay unresolved (ResolvedRefs=False). */
+    private BackendResolveResult parseBackendRefs(final JsonArray backendRefs, final String namespace,
+                                                  final String routeName) {
+        List<ResolvedBackend> backends = new ArrayList<>();
+        int unresolvedCount = 0;
+        int invalidWeightedShare = 0;
+        String unresolvedReason = null;
+        for (JsonElement element : backendRefs) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            BackendRefOutcome outcome = resolveBackendRef(element.getAsJsonObject(), namespace, routeName);
+            if (Objects.nonNull(outcome.unresolvedReason)) {
+                unresolvedCount++;
+                invalidWeightedShare += Math.max(0, outcome.declaredWeight);
+                if (Objects.isNull(unresolvedReason)) {
+                    unresolvedReason = outcome.unresolvedReason;
+                }
+                continue;
+            }
+            backends.add(new ResolvedBackend(outcome.declaredWeight, outcome.urls));
+        }
+        // One fail-target entry with exactly the invalid share keeps the split proportional.
+        if (invalidWeightedShare > 0) {
+            backends.add(new ResolvedBackend(invalidWeightedShare, List.of(FAIL_TARGET_URL)));
+        }
+        return new BackendResolveResult(buildUpstreams(backends), unresolvedCount, unresolvedReason);
+    }
+
+    /** Spread declared weights over endpoints with one common scale factor, so aggregate weights stay proportional regardless of replica counts. */
+    private List<DivideUpstream> buildUpstreams(final List<ResolvedBackend> backends) {
+        long scale = 1;
+        for (ResolvedBackend backend : backends) {
+            if (!backend.urls.isEmpty()) {
+                scale = Math.max(scale, divideRoundingUp(backend.urls.size(), backend.declaredWeight));
+            }
+        }
+        List<DivideUpstream> upstreams = new ArrayList<>();
+        for (ResolvedBackend backend : backends) {
+            if (backend.urls.isEmpty()) {
+                continue;
+            }
+            int perEndpointWeight = Math.max(1, (int) Math.min(Integer.MAX_VALUE,
+                    Math.round(backend.declaredWeight * (double) scale / backend.urls.size())));
+            for (String url : backend.urls) {
+                DivideUpstream upstream = new DivideUpstream();
+                upstream.setUpstreamUrl(url);
+                upstream.setWeight(perEndpointWeight);
+                upstream.setProtocol("http://");
+                upstream.setWarmup(0);
+                upstream.setStatus(true);
+                upstream.setUpstreamHost("");
+                // Constant timestamp keeps the handle json byte-identical for the unchanged-check.
+                upstream.setTimestamp(0L);
+                upstreams.add(upstream);
+            }
+        }
+        return upstreams;
+    }
+
+    private long divideRoundingUp(final long dividend, final long divisor) {
+        return (dividend + divisor - 1) / divisor;
+    }
+
+    /** Resolve one backendRef into upstream URLs or the Gateway API reason of its failure. */
+    private BackendRefOutcome resolveBackendRef(final JsonObject backendRef, final String namespace,
+                                                final String routeName) {
+        String serviceName = JsonFields.getString(backendRef, "name");
+        if (Objects.isNull(serviceName)) {
+            return BackendRefOutcome.ok(List.of(), 0);
+        }
+        // An omitted weight defaults to 1
+        int weight = backendRef.has("weight") && backendRef.get("weight").isJsonPrimitive()
+                ? backendRef.get("weight").getAsInt() : 1;
+        String backendNamespace = JsonFields.getString(backendRef, "namespace");
+        if (Objects.isNull(backendNamespace)) {
+            backendNamespace = namespace;
+        }
+        if (!GatewayApiConstants.isServiceRef(backendRef)) {
+            LOG.warn("HTTPRoute {}/{} backendRef to group '{}' kind '{}' is not supported, only core Service",
+                    namespace, routeName, JsonFields.getString(backendRef, "group"),
+                    JsonFields.getString(backendRef, "kind"));
+            return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_INVALID_KIND, weight);
+        }
+        if (!backendNamespace.equals(namespace)
+                && !ReferenceGrants.isGranted(referenceGrantLister, backendNamespace, namespace,
+                GatewayApiConstants.CORE_API_GROUP, GatewayApiConstants.SERVICE_KIND, serviceName)) {
+            LOG.warn("HTTPRoute {}/{} backendRef to Service {}/{} is not permitted by a ReferenceGrant",
+                    namespace, routeName, backendNamespace, serviceName);
+            return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_REF_NOT_PERMITTED, weight);
+        }
+        V1Endpoints v1Endpoints = endpointsLister.namespace(backendNamespace).get(serviceName);
+        if (Objects.isNull(v1Endpoints) || CollectionUtils.isEmpty(v1Endpoints.getSubsets())) {
+            LOG.warn("Cannot find endpoints for service {}/{}", backendNamespace, serviceName);
+            return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_BACKEND_NOT_FOUND, weight);
+        }
+        List<String> readyIps = new ArrayList<>();
+        Set<Long> endpointPorts = new LinkedHashSet<>();
+        Map<String, Long> endpointPortsByName = new HashMap<>();
+        for (V1EndpointSubset subset : v1Endpoints.getSubsets()) {
+            if (Objects.nonNull(subset.getPorts())) {
+                for (CoreV1EndpointPort endpointPort : subset.getPorts()) {
+                    if (Objects.nonNull(endpointPort.getPort())) {
+                        endpointPorts.add(endpointPort.getPort().longValue());
+                        if (Objects.nonNull(endpointPort.getName())) {
+                            endpointPortsByName.putIfAbsent(endpointPort.getName(), endpointPort.getPort().longValue());
+                        }
+                    }
+                }
+            }
+            if (CollectionUtils.isEmpty(subset.getAddresses())) {
+                continue;
+            }
+            for (V1EndpointAddress address : subset.getAddresses()) {
+                if (Objects.nonNull(address.getIp())) {
+                    readyIps.add(address.getIp());
+                }
+            }
+        }
+        // Endpoints existed but yielded no ready address → treat as unresolved
+        if (readyIps.isEmpty()) {
+            return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_BACKEND_NOT_FOUND, weight);
+        }
+        V1Service service = serviceLister.namespace(backendNamespace).get(serviceName);
+        Long targetPort = resolveTargetPort(service, endpointPorts, endpointPortsByName,
+                JsonFields.getLong(backendRef, "port"), namespace, routeName, backendNamespace, serviceName);
+        if (Objects.isNull(targetPort)) {
+            return BackendRefOutcome.unresolved(GatewayApiConstants.REASON_BACKEND_NOT_FOUND, weight);
+        }
+        // Spec: weight 0 removes the backend from rotation.
+        if (weight == 0) {
+            return BackendRefOutcome.ok(List.of(), 0);
+        }
+        List<String> urls = new ArrayList<>();
+        for (String ip : readyIps) {
+            urls.add(ip + ":" + targetPort);
+        }
+        return BackendRefOutcome.ok(urls, weight);
+    }
+
+    /**
+     * Map the backendRef port to the pod port via the Service spec (targetPort numeric or
+     * named, resolved against Endpoints); without a cached Service, fall back to a
+     * single-endpoint-port heuristic and report ambiguity as BackendNotFound.
+     */
+    private Long resolveTargetPort(final V1Service service, final Set<Long> endpointPorts,
+                                   final Map<String, Long> endpointPortsByName, final Long servicePort,
+                                   final String namespace, final String routeName,
+                                   final String backendNamespace, final String serviceName) {
+        List<V1ServicePort> servicePorts = Objects.isNull(service) || Objects.isNull(service.getSpec())
+                || Objects.isNull(service.getSpec().getPorts()) ? List.of() : service.getSpec().getPorts();
+        if (!servicePorts.isEmpty()) {
+            V1ServicePort selected = selectServicePort(servicePorts, servicePort);
+            if (Objects.isNull(selected)) {
+                LOG.warn("HTTPRoute {}/{} backendRef to Service {}/{}: no service port matches {}",
+                        namespace, routeName, backendNamespace, serviceName,
+                        Objects.isNull(servicePort) ? "the multiple ports of the service" : servicePort);
+                return null;
+            }
+            IntOrString targetPort = selected.getTargetPort();
+            if (Objects.nonNull(targetPort) && targetPort.isInteger()) {
+                return targetPort.getIntValue().longValue();
+            }
+            if (Objects.nonNull(targetPort)) {
+                Long resolved = endpointPortsByName.get(targetPort.getStrValue());
+                if (Objects.nonNull(resolved)) {
+                    return resolved;
+                }
+                LOG.warn("HTTPRoute {}/{} backendRef to Service {}/{}: named targetPort '{}' not found in endpoints",
+                        namespace, routeName, backendNamespace, serviceName, targetPort.getStrValue());
+                return null;
+            }
+            return Objects.isNull(selected.getPort()) ? null : selected.getPort().longValue();
+        }
+        if (endpointPorts.size() == 1) {
+            return endpointPorts.iterator().next();
+        }
+        if (Objects.nonNull(servicePort) && (endpointPorts.isEmpty() || endpointPorts.contains(servicePort))) {
+            return servicePort;
+        }
+        LOG.warn("HTTPRoute {}/{} backendRef to Service {}/{}: cannot map service port {} to an endpoint port {}",
+                namespace, routeName, backendNamespace, serviceName, servicePort, endpointPorts);
+        return null;
+    }
+
+    /** The Service port entry a backendRef port selects; required unless the Service has exactly one port. */
+    private V1ServicePort selectServicePort(final List<V1ServicePort> servicePorts, final Long servicePort) {
+        if (Objects.nonNull(servicePort)) {
+            for (V1ServicePort port : servicePorts) {
+                if (Objects.nonNull(port.getPort()) && port.getPort().longValue() == servicePort) {
+                    return port;
+                }
+            }
+            return null;
+        }
+        return servicePorts.size() == 1 ? servicePorts.get(0) : null;
+    }
+
+    private void appendMatchConditions(final List<ConditionData> conditions, final JsonObject match) {
+        JsonObject path = JsonFields.getJsonObject(match, "path");
+        String pathValue = JsonFields.getString(path, "value");
+        if (Objects.nonNull(pathValue)) {
+            ConditionData pathCondition = new ConditionData();
+            pathCondition.setParamType(ParamTypeEnum.URI.getName());
+            String pathType = JsonFields.getString(path, "type");
+            if (Objects.isNull(pathType) || "PathPrefix".equals(pathType)) {
+                // Prefixes match on element boundaries (/foo ≠ /foobar); raw startsWith cannot, hence regex.
+                pathCondition.setOperator(OperatorEnum.REGEX.getAlias());
+                pathCondition.setParamValue(prefixRegex(pathValue));
+            } else {
+                pathCondition.setOperator(mapPathType(pathType));
+                pathCondition.setParamValue(pathValue);
+            }
+            conditions.add(pathCondition);
+        }
+
+        String method = JsonFields.getString(match, "method");
+        if (Objects.nonNull(method)) {
+            ConditionData methodCondition = new ConditionData();
+            methodCondition.setParamType(ParamTypeEnum.REQUEST_METHOD.getName());
+            methodCondition.setOperator(OperatorEnum.EQ.getAlias());
+            methodCondition.setParamValue(method);
+            conditions.add(methodCondition);
+        }
+
+        JsonArray headers = JsonFields.getJsonArray(match, "headers");
+        if (Objects.nonNull(headers)) {
+            for (JsonElement headerElement : headers) {
+                if (!headerElement.isJsonObject()) {
+                    continue;
+                }
+                JsonObject header = headerElement.getAsJsonObject();
+                ConditionData headerCondition = new ConditionData();
+                headerCondition.setParamType(ParamTypeEnum.HEADER.getName());
+                headerCondition.setOperator(exactOrRegex(JsonFields.getString(header, "type")));
+                headerCondition.setParamName(JsonFields.getString(header, "name"));
+                headerCondition.setParamValue(JsonFields.getString(header, "value"));
+                conditions.add(headerCondition);
+            }
+        }
+
+        JsonArray queryParams = JsonFields.getJsonArray(match, "queryParams");
+        if (Objects.nonNull(queryParams)) {
+            for (JsonElement queryElement : queryParams) {
+                if (!queryElement.isJsonObject()) {
+                    continue;
+                }
+                JsonObject queryParam = queryElement.getAsJsonObject();
+                ConditionData queryCondition = new ConditionData();
+                queryCondition.setParamType(ParamTypeEnum.QUERY.getName());
+                queryCondition.setOperator(exactOrRegex(JsonFields.getString(queryParam, "type")));
+                queryCondition.setParamName(JsonFields.getString(queryParam, "name"));
+                queryCondition.setParamValue(JsonFields.getString(queryParam, "value"));
+                conditions.add(queryCondition);
+            }
+        }
+    }
+
+    /** Anchored full-match regex for a prefix: {@code /foo} → {@code ^\Q/foo\E(/.*)?$}; root {@code /} is the catch-all. */
+    private String prefixRegex(final String prefix) {
+        String stripped = prefix.length() > 1 && prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix;
+        if ("/".equals(stripped)) {
+            return "^/.*$";
+        }
+        return "^" + Pattern.quote(stripped) + "(/.*)?$";
+    }
+
+    /** Full spec precedence packed into the sort, rule index as the final tie-break; lower sort wins. */
+    private int selectorSort(final String hostname, final JsonObject match, final int ruleIndex) {
+        int score = hostnameScore(hostname) << 24
+                | pathScore(match) << 13
+                | (Objects.isNull(JsonFields.getString(match, "method")) ? 0 : 1) << 12
+                | matchCount(match, "headers") << 8
+                | matchCount(match, "queryParams") << 4
+                | 15 - Math.min(ruleIndex, 15);
+        return SORT_PRECEDENCE_BASE - score;
+    }
+
+    /** Exact beats wildcard, more wildcard suffix labels beat fewer; no hostname sorts below both. */
+    private int hostnameScore(final String hostname) {
+        if (Objects.isNull(hostname)) {
+            return 0;
+        }
+        if (hostname.startsWith("*.")) {
+            return Math.min(hostname.substring(2).split("\\.").length, 6);
+        }
+        return 7;
+    }
+
+    /** Exact beats prefix, longer prefix beats shorter; the 11-bit field covers the CRD's 1024-char path maximum. */
+    private int pathScore(final JsonObject match) {
+        JsonObject path = JsonFields.getJsonObject(match, "path");
+        String pathValue = JsonFields.getString(path, "value");
+        if (Objects.isNull(pathValue)) {
+            return 0;
+        }
+        String pathType = JsonFields.getString(path, "type");
+        if ("Exact".equals(pathType)) {
+            return 2047;
+        }
+        if ("RegularExpression".equals(pathType)) {
+            return 1;
+        }
+        return 2 + Math.min(pathValue.length() - 1, 2044);
+    }
+
+    /** Number of header/query matches (4 bits each, capped), higher is more specific. */
+    private int matchCount(final JsonObject match, final String field) {
+        JsonArray array = JsonFields.getJsonArray(match, field);
+        return Objects.isNull(array) ? 0 : Math.min(array.size(), 15);
+    }
+
+    private String mapPathType(final String pathType) {
+        if ("Exact".equals(pathType)) {
+            return OperatorEnum.EQ.getAlias();
+        }
+        if ("RegularExpression".equals(pathType)) {
+            return OperatorEnum.REGEX.getAlias();
+        }
+        return OperatorEnum.STARTS_WITH.getAlias();
+    }
+
+    /** Regex must map to REGEX: the MATCH judge compares by substring containment, not regex semantics. */
+    private String exactOrRegex(final String matchType) {
+        return "RegularExpression".equals(matchType) ? OperatorEnum.REGEX.getAlias() : OperatorEnum.EQ.getAlias();
+    }
+
+    /** One backendRef outcome: URLs with declared weight, or the failure reason. */
+    private static final class BackendRefOutcome {
+
+        private final List<String> urls;
+
+        private final int declaredWeight;
+
+        private final String unresolvedReason;
+
+        private BackendRefOutcome(final List<String> urls, final int declaredWeight, final String unresolvedReason) {
+            this.urls = urls;
+            this.declaredWeight = declaredWeight;
+            this.unresolvedReason = unresolvedReason;
+        }
+
+        static BackendRefOutcome ok(final List<String> urls, final int declaredWeight) {
+            return new BackendRefOutcome(urls, declaredWeight, null);
+        }
+
+        static BackendRefOutcome unresolved(final String reason, final int declaredWeight) {
+            return new BackendRefOutcome(List.of(), declaredWeight, reason);
+        }
+    }
+
+    /** One resolved backendRef: its declared weight and the pod addresses it fans out to. */
+    private static final class ResolvedBackend {
+
+        private final int declaredWeight;
+
+        private final List<String> urls;
+
+        ResolvedBackend(final int declaredWeight, final List<String> urls) {
+            this.declaredWeight = declaredWeight;
+            this.urls = urls;
+        }
+    }
+
+    /** Rule-level result; non-zero unresolvedCount means the reconciler reports ResolvedRefs=False. */
+    private static final class BackendResolveResult {
+
+        private final List<DivideUpstream> upstreams;
+
+        private final int unresolvedCount;
+
+        private final String unresolvedReason;
+
+        BackendResolveResult(final List<DivideUpstream> upstreams, final int unresolvedCount,
+                             final String unresolvedReason) {
+            this.upstreams = upstreams;
+            this.unresolvedCount = unresolvedCount;
+            this.unresolvedReason = unresolvedReason;
+        }
+    }
+
+    /** Backend resolution failures across rules; the first reason becomes the route-level one. */
+    private static final class ResolveState {
+
+        private boolean anyUnresolved;
+
+        private String reason;
+
+        private boolean unsupportedFilters;
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayClassReconciler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayClassReconciler.java
new file mode 100644
index 0000000000..775cbdae4c
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayClassReconciler.java
@@ -0,0 +1,521 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.GatewayApiCrdVersions;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.apache.shenyu.k8s.common.StatusMergePatch;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Accepts GatewayClasses whose controllerName matches ShenYu's; reports the
+ * SupportedVersion condition and supportedFeatures, keeps the spec-defined finalizer while
+ * Gateways use the class, rejects parametersRef classes (ShenYu has no class-level
+ * parameters) and re-queues served Gateways on deletion or ownership loss.
+ */
+public class GatewayClassReconciler implements Reconciler {
+
+    private static final Logger LOG = LoggerFactory.getLogger(GatewayClassReconciler.class);
+
+    private static final String GATEWAY_CLASS_KIND = "GatewayClass";
+
+    private static final String GATEWAYCLASSES_RESOURCE = "gatewayclasses";
+
+    /** Official conformance FeatureName strings; sorted ascending (spec) and limited to implemented capabilities. */
+    private static final List<String> SUPPORTED_FEATURES = List.of("Gateway", "HTTPRoute", "ReferenceGrant");
+
+    private final Lister<DynamicKubernetesObject> gatewayClassLister;
+
+    private final Lister<DynamicKubernetesObject> gatewayLister;
+
+    private final RateLimitingQueue<Request> gatewayWorkQueue;
+
+    private final ApiClient apiClient;
+
+    private final GatewayApiCrdVersions crdVersions;
+
+    public GatewayClassReconciler(final SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer,
+                                  final SharedIndexInformer<DynamicKubernetesObject> gatewayInformer,
+                                  final RateLimitingQueue<Request> gatewayWorkQueue,
+                                  final ApiClient apiClient,
+                                  final GatewayApiCrdVersions crdVersions) {
+        this.gatewayClassLister = new Lister<>(gatewayClassInformer.getIndexer());
+        this.gatewayLister = new Lister<>(gatewayInformer.getIndexer());
+        this.gatewayWorkQueue = gatewayWorkQueue;
+        this.apiClient = apiClient;
+        this.crdVersions = crdVersions;
+    }
+
+    @Override
+    public Result reconcile(final Request request) {
+        LOG.info("Starting to reconcile GatewayClass {}", request.getName());
+        try {
+            DynamicKubernetesObject gatewayClass = gatewayClassLister.get(request.getName());
+
+            if (Objects.isNull(gatewayClass)) {
+                LOG.info("GatewayClass {} deleted, re-queuing affected Gateways", request.getName());
+                requeueAffectedGateways(request.getName());
+                return new Result(false);
+            }
+
+            if (isDeleting(gatewayClass)) {
+                // No status writes on a deleting object; drain Gateways, then release the finalizer.
+                requeueAffectedGateways(request.getName());
+                reconcileFinalizer(gatewayClass, false);
+                return new Result(false);
+            }
+
+            if (!isShenyuGatewayClass(gatewayClass)) {
+                boolean wasAcceptedByShenyu = GatewayApiConstants.isConditionAcceptedByShenyu(gatewayClass, "Accepted");
+                boolean anyGatewayRequeued = requeuePreviouslyServedGateways(request.getName());
+                if (wasAcceptedByShenyu || anyGatewayRequeued) {
+                    LOG.info("GatewayClass {} is no longer managed by ShenYu, re-queuing affected Gateways", request.getName());
+                }
+                if (wasAcceptedByShenyu) {
+                    updateGatewayClassRejectedStatus(gatewayClass, GatewayApiConstants.REASON_UNSUPPORTED,
+                            "GatewayClass is not managed by the ShenYu controller");
+                }
+                // Our finalizer must not outlive the last Gateway once another controller owns the class.
+                reconcileFinalizer(gatewayClass, false);
+                return new Result(false);
+            }
+
+            if (hasParametersRef(gatewayClass)) {
+                // Reject instead of silently ignoring class-level parameters we cannot honor.
+                boolean wasAcceptedByShenyu = GatewayApiConstants.isConditionAcceptedByShenyu(gatewayClass, "Accepted");
+                updateGatewayClassRejectedStatus(gatewayClass, GatewayApiConstants.REASON_INVALID_PARAMETERS,
+                        "GatewayClass parametersRef is not supported by the ShenYu controller");
+                if (wasAcceptedByShenyu) {
+                    requeueAffectedGateways(request.getName());
+                }
+                reconcileFinalizer(gatewayClass, false);
+                return new Result(false);
+            }
+
+            // Requeue only on the Accepted transition; plain resyncs would waste a cluster scan.
+            boolean wasAccepted = GatewayApiConstants.isConditionTrue(gatewayClass, "Accepted");
+            updateGatewayClassAcceptedStatus(gatewayClass);
+            if (!wasAccepted) {
+                requeueAffectedGateways(request.getName());
+            }
+            reconcileFinalizer(gatewayClass, true);
+            LOG.debug("GatewayClass {} reconciled successfully", request.getName());
+            return new Result(false);
+        } catch (Exception e) {
+            LOG.error("Error reconciling GatewayClass {}, will retry", request.getName(), e);
+            return new Result(true);
+        }
+    }
+
+    /**
+     * Whether the class's controllerName matches ShenYu's.
+     *
+     * @param gatewayClass the GatewayClass dynamic object
+     * @return true if the GatewayClass's controllerName matches ShenYu's controller name
+     */
+    public static boolean isShenyuGatewayClass(final DynamicKubernetesObject gatewayClass) {
+        if (Objects.isNull(gatewayClass)) {
+            return false;
+        }
+        JsonObject spec = gatewayClass.getRaw().getAsJsonObject("spec");
+        if (Objects.isNull(spec) || !spec.has("controllerName") || spec.get("controllerName").isJsonNull()) {
+            return false;
+        }
+        String controllerName = spec.get("controllerName").getAsString();
+        return GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(controllerName);
+    }
+
+    /**
+     * Whether the Gateway's class is ShenYu-owned and not rejected; shared by the Gateway and
+     * HTTPRoute reconcilers. Accepted=False puts the Gateway out of scope; an absent Accepted is
+     * treated optimistically for the startup race (Gateway reconciled before the class status
+     * patch reaches the cache) and enforced by the periodic resync.
+     *
+     * @param gateway the Gateway dynamic object
+     * @param gatewayClassLister lister for GatewayClass (cluster-scoped)
+     * @return true if the Gateway's class is owned by ShenYu
+     */
+    public static boolean isShenyuGateway(final DynamicKubernetesObject gateway,
+                                          final Lister<DynamicKubernetesObject> gatewayClassLister) {
+        if (Objects.isNull(gateway)) {
+            return false;
+        }
+        JsonObject spec = gateway.getRaw().getAsJsonObject("spec");
+        if (Objects.isNull(spec) || !spec.has("gatewayClassName") || spec.get("gatewayClassName").isJsonNull()) {
+            return false;
+        }
+        String gatewayClassName = spec.get("gatewayClassName").getAsString();
+        DynamicKubernetesObject gatewayClass = gatewayClassLister.get(gatewayClassName);
+        if (!isShenyuGatewayClass(gatewayClass)) {
+            return false;
+        }
+        JsonObject rejected = GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED);
+        return Objects.isNull(rejected) || !"False".equals(JsonFields.getString(rejected, "status"));
+    }
+
+    /** Whether the class carries a spec.parametersRef ShenYu cannot honor. */
+    private static boolean hasParametersRef(final DynamicKubernetesObject gatewayClass) {
+        JsonObject spec = JsonFields.getJsonObject(gatewayClass.getRaw(), "spec");
+        JsonObject parametersRef = JsonFields.getJsonObject(spec, "parametersRef");
+        return Objects.nonNull(parametersRef);
+    }
+
+    private static boolean isDeleting(final DynamicKubernetesObject gatewayClass) {
+        JsonObject metadata = JsonFields.getJsonObject(gatewayClass.getRaw(), "metadata");
+        return Objects.nonNull(JsonFields.getString(metadata, "deletionTimestamp"));
+    }
+
+    /** Re-queue Gateways referencing this class (Accepted transition, deletion cascade). */
+    private void requeueAffectedGateways(final String gatewayClassName) {
+        for (DynamicKubernetesObject gateway : gatewayLister.list()) {
+            if (referencesGatewayClass(gateway, gatewayClassName)) {
+                String ns = Objects.requireNonNull(gateway.getMetadata()).getNamespace();
+                String name = gateway.getMetadata().getName();
+                gatewayWorkQueue.add(new Request(ns, name));
+                LOG.info("Re-queued Gateway {}/{} due to GatewayClass {} change", ns, name, gatewayClassName);
+            }
+        }
+    }
+
+    /**
+     * Re-queue only previously served Gateways on ownership loss; the rest belong to the new controller.
+     *
+     * @param gatewayClassName name of the GatewayClass
+     * @return whether any Gateway was re-queued
+     */
+    private boolean requeuePreviouslyServedGateways(final String gatewayClassName) {
+        boolean anyRequeued = false;
+        for (DynamicKubernetesObject gateway : gatewayLister.list()) {
+            if (!referencesGatewayClass(gateway, gatewayClassName)) {
+                continue;
+            }
+            String ns = Objects.requireNonNull(gateway.getMetadata()).getNamespace();
+            String name = gateway.getMetadata().getName();
+            boolean servedByShenyu = CollectionUtils.isNotEmpty(GatewayRouteCache.getInstance().getRoutesByGateway(ns, name))
+                    || GatewayApiConstants.isConditionAcceptedByShenyu(gateway, GatewayApiConstants.CONDITION_ACCEPTED);
+            if (!servedByShenyu) {
+                continue;
+            }
+            gatewayWorkQueue.add(new Request(ns, name));
+            LOG.info("Re-queued Gateway {}/{} after GatewayClass {} ownership loss", ns, name, gatewayClassName);
+            anyRequeued = true;
+        }
+        return anyRequeued;
+    }
+
+    private boolean referencesGatewayClass(final DynamicKubernetesObject gateway, final String gatewayClassName) {
+        JsonObject spec = gateway.getRaw().getAsJsonObject("spec");
+        if (Objects.isNull(spec) || !spec.has("gatewayClassName") || spec.get("gatewayClassName").isJsonNull()) {
+            return false;
+        }
+        return gatewayClassName.equals(spec.get("gatewayClassName").getAsString());
+    }
+
+    /** Finalizer present while an accepted class has Gateways, removed once none remain (also releases a deleting class). */
+    private void reconcileFinalizer(final DynamicKubernetesObject gatewayClass, final boolean acceptedByShenyu) {
+        final String name = gatewayClass.getMetadata().getName();
+        JsonArray existing = finalizersOf(gatewayClass);
+        boolean hasOurs = containsFinalizer(existing);
+        boolean anyGateway = anyGatewayReferences(name);
+        try {
+            if (anyGateway && !hasOurs && acceptedByShenyu) {
+                JsonArray desired = new JsonArray();
+                for (JsonElement element : existing) {
+                    desired.add(element);
+                }
+                desired.add(GatewayApiConstants.GATEWAY_CLASS_FINALIZER);
+                patchGatewayClassFinalizers(name, desired);
+                LOG.info("Added finalizer to GatewayClass {} in use", name);
+            } else if (!anyGateway && hasOurs) {
+                JsonArray desired = new JsonArray();
+                for (JsonElement element : existing) {
+                    if (!GatewayApiConstants.GATEWAY_CLASS_FINALIZER.equals(getAsStringOrNull(element))) {
+                        desired.add(element);
+                    }
+                }
+                patchGatewayClassFinalizers(name, desired);
+                LOG.info("Removed finalizer from GatewayClass {} no longer in use", name);
+            }
+        } catch (Exception e) {
+            LOG.warn("Failed to reconcile GatewayClass {} finalizer, will retry on next resync", name, e);
+        }
+    }
+
+    private static JsonArray finalizersOf(final DynamicKubernetesObject gatewayClass) {
+        JsonObject metadata = JsonFields.getJsonObject(gatewayClass.getRaw(), "metadata");
+        JsonArray finalizers = JsonFields.getJsonArray(metadata, "finalizers");
+        return Objects.isNull(finalizers) ? new JsonArray() : finalizers;
+    }
+
+    private static boolean containsFinalizer(final JsonArray finalizers) {
+        for (JsonElement element : finalizers) {
+            if (GatewayApiConstants.GATEWAY_CLASS_FINALIZER.equals(getAsStringOrNull(element))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private static String getAsStringOrNull(final JsonElement element) {
+        return Objects.nonNull(element) && element.isJsonPrimitive() ? element.getAsString() : null;
+    }
+
+    private boolean anyGatewayReferences(final String gatewayClassName) {
+        for (DynamicKubernetesObject gateway : gatewayLister.list()) {
+            if (referencesGatewayClass(gateway, gatewayClassName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Merge-patch the main resource (not the /status subresource) to set metadata.finalizers. */
+    private void patchGatewayClassFinalizers(final String name, final JsonArray finalizers) throws ApiException {
+        JsonObject body = new JsonObject();
+        body.addProperty("kind", GATEWAY_CLASS_KIND);
+        body.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION);
+
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("name", name);
+        metadata.add("finalizers", finalizers);
+        body.add("metadata", metadata);
+
+        String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+                + "/" + GATEWAYCLASSES_RESOURCE + "/" + name;
+
+        StatusMergePatch.patch(apiClient, path, body);
+    }
+
+    /** Patch Accepted=True + SupportedVersion + supportedFeatures; skipped while the existing status already matches at the current generation. */
+    private void updateGatewayClassAcceptedStatus(final DynamicKubernetesObject gatewayClass) {
+        Long generation = JsonFields.getLong(JsonFields.getJsonObject(gatewayClass.getRaw(), "metadata"), "generation");
+        if (acceptedStatusUpToDate(gatewayClass, generation)) {
+            return;
+        }
+        try {
+            final String name = gatewayClass.getMetadata().getName();
+
+            JsonObject accepted = new JsonObject();
+            accepted.addProperty("type", GatewayApiConstants.CONDITION_ACCEPTED);
+            accepted.addProperty("status", "True");
+            accepted.addProperty("reason", GatewayApiConstants.CONDITION_ACCEPTED);
+            accepted.addProperty("message", "GatewayClass has been accepted by the ShenYu controller");
+            if (Objects.nonNull(generation)) {
+                accepted.addProperty("observedGeneration", generation);
+            }
+            accepted.addProperty("lastTransitionTime", Instant.now().toString());
+            preserveTransitionTime(GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED), accepted);
+
+            boolean supported = crdVersions.isSupported();
+            JsonObject supportedVersion = new JsonObject();
+            supportedVersion.addProperty("type", GatewayApiConstants.CONDITION_SUPPORTED_VERSION);
+            supportedVersion.addProperty("status", supported ? "True" : "False");
+            supportedVersion.addProperty("reason", supported
+                    ? GatewayApiConstants.REASON_SUPPORTED_VERSION : GatewayApiConstants.REASON_UNSUPPORTED_VERSION);
+            supportedVersion.addProperty("message", "detected Gateway API CRD bundle version(s): "
+                    + crdVersions.describeDetected() + "; supported: >= v1.5.0");
+            if (Objects.nonNull(generation)) {
+                supportedVersion.addProperty("observedGeneration", generation);
+            }
+            supportedVersion.addProperty("lastTransitionTime", Instant.now().toString());
+            preserveTransitionTime(GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_SUPPORTED_VERSION), supportedVersion);
+
+            JsonArray conditions = buildGatewayClassStatusConditions(gatewayClass, accepted, supportedVersion);
+
+            JsonObject statusObj = new JsonObject();
+            statusObj.add("conditions", conditions);
+            statusObj.add("supportedFeatures", buildSupportedFeatures());
+
+            patchGatewayClassStatus(name, statusObj);
+            LOG.info("Updated GatewayClass {} status to Accepted=True", name);
+        } catch (Exception e) {
+            LOG.warn("Failed to update GatewayClass status, will retry on next resync", e);
+        }
+    }
+
+    /** Accepted=False with the given reason; skipped when already saying the same at the current generation. */
+    private void updateGatewayClassRejectedStatus(final DynamicKubernetesObject gatewayClass, final String reason,
+                                                  final String message) {
+        Long generation = JsonFields.getLong(JsonFields.getJsonObject(gatewayClass.getRaw(), "metadata"), "generation");
+        JsonObject existing = GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED);
+        if (Objects.nonNull(existing)
+                && "False".equals(JsonFields.getString(existing, "status"))
+                && reason.equals(JsonFields.getString(existing, "reason"))
+                && observedGenerationUpToDate(existing, generation)) {
+            return;
+        }
+        try {
+            final String name = gatewayClass.getMetadata().getName();
+
+            JsonObject condition = new JsonObject();
+            condition.addProperty("type", GatewayApiConstants.CONDITION_ACCEPTED);
+            condition.addProperty("status", "False");
+            condition.addProperty("reason", reason);
+            condition.addProperty("message", message);
+            if (Objects.nonNull(generation)) {
+                condition.addProperty("observedGeneration", generation);
+            }
+            condition.addProperty("lastTransitionTime", Instant.now().toString());
+
+            JsonArray conditions = buildGatewayClassStatusConditions(gatewayClass, condition);
+
+            JsonObject statusObj = new JsonObject();
+            statusObj.add("conditions", conditions);
+
+            patchGatewayClassStatus(name, statusObj);
+            LOG.info("Updated GatewayClass {} status to Accepted=False ({})", name, reason);
+        } catch (Exception e) {
+            LOG.warn("Failed to update GatewayClass status, will retry on next resync", e);
+        }
+    }
+
+    private void patchGatewayClassStatus(final String name, final JsonObject statusObj) throws ApiException {
+        JsonObject body = new JsonObject();
+        body.add("status", statusObj);
+        body.addProperty("kind", GATEWAY_CLASS_KIND);
+        body.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION);
+
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("name", name);
+        body.add("metadata", metadata);
+
+        String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+                + "/" + GATEWAYCLASSES_RESOURCE + "/" + name + "/status";
+
+        StatusMergePatch.patch(apiClient, path, body);
+    }
+
+    private boolean acceptedStatusUpToDate(final DynamicKubernetesObject gatewayClass, final Long generation) {
+        JsonObject existingAccepted = GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED);
+        if (!GatewayApiConstants.isConditionTrue(gatewayClass, GatewayApiConstants.CONDITION_ACCEPTED)
+                || !observedGenerationUpToDate(existingAccepted, generation)) {
+            return false;
+        }
+        JsonObject existingVersion = GatewayApiConstants.findCondition(gatewayClass, GatewayApiConstants.CONDITION_SUPPORTED_VERSION);
+        boolean supported = crdVersions.isSupported();
+        if (Objects.isNull(existingVersion)
+                || !(supported ? "True" : "False").equals(JsonFields.getString(existingVersion, "status"))
+                || !supportedVersionReason(supported).equals(JsonFields.getString(existingVersion, "reason"))) {
+            return false;
+        }
+        return supportedFeaturesMatch(gatewayClass);
+    }
+
+    private String supportedVersionReason(final boolean supported) {
+        return supported ? GatewayApiConstants.REASON_SUPPORTED_VERSION : GatewayApiConstants.REASON_UNSUPPORTED_VERSION;
+    }
+
+    private boolean supportedFeaturesMatch(final DynamicKubernetesObject gatewayClass) {
+        JsonObject status = JsonFields.getJsonObject(gatewayClass.getRaw(), "status");
+        JsonArray existing = JsonFields.getJsonArray(status, "supportedFeatures");
+        if (Objects.isNull(existing) || existing.size() != SUPPORTED_FEATURES.size()) {
+            return false;
+        }
+        List<String> names = new ArrayList<>();
+        for (JsonElement element : existing) {
+            JsonObject feature = element.isJsonObject() ? element.getAsJsonObject() : null;
+            String name = Objects.isNull(feature) ? null : JsonFields.getString(feature, "name");
+            if (Objects.isNull(name)) {
+                return false;
+            }
+            names.add(name);
+        }
+        return names.equals(SUPPORTED_FEATURES);
+    }
+
+    private static JsonArray buildSupportedFeatures() {
+        JsonArray features = new JsonArray();
+        for (String name : SUPPORTED_FEATURES) {
+            JsonObject feature = new JsonObject();
+            feature.addProperty("name", name);
+            features.add(feature);
+        }
+        return features;
+    }
+
+    private boolean observedGenerationUpToDate(final JsonObject existingCondition, final Long generation) {
+        if (Objects.isNull(generation)) {
+            return true;
+        }
+        return Objects.nonNull(existingCondition)
+                && generation.equals(JsonFields.getLong(existingCondition, "observedGeneration"));
+    }
+
+    /** Keep lastTransitionTime when type and status are unchanged; a generation refresh is not a transition. */
+    private void preserveTransitionTime(final JsonObject existingCondition, final JsonObject desiredCondition) {
+        if (Objects.isNull(existingCondition)
+                || !Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+                || !Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))) {
+            return;
+        }
+        String existingTime = JsonFields.getString(existingCondition, "lastTransitionTime");
+        if (Objects.nonNull(existingTime)) {
+            desiredCondition.addProperty("lastTransitionTime", existingTime);
+        }
+    }
+
+    /** Own conditions plus foreign ones: merge-patch replaces arrays wholesale. */
+    private JsonArray buildGatewayClassStatusConditions(final DynamicKubernetesObject gatewayClass,
+                                                        final JsonObject... ownConditions) {
+        Set<String> ownTypes = new HashSet<>();
+        JsonArray conditions = new JsonArray();
+        for (JsonObject ownCondition : ownConditions) {
+            conditions.add(ownCondition);
+            ownTypes.add(JsonFields.getString(ownCondition, "type"));
+        }
+
+        JsonObject raw = gatewayClass.getRaw();
+        if (raw.has("status") && !raw.get("status").isJsonNull()) {
+            JsonObject status = raw.getAsJsonObject("status");
+            if (status.has("conditions") && !status.get("conditions").isJsonNull()) {
+                for (JsonElement el : status.getAsJsonArray("conditions")) {
+                    JsonObject existing = el.getAsJsonObject();
+                    String existingType = existing.has("type") ? existing.get("type").getAsString() : null;
+                    // Drop our stale entries of the same types; keep everything else.
+                    if (!ownTypes.contains(existingType)) {
+                        conditions.add(existing);
+                    }
+                }
+            }
+        }
+        return conditions;
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayReconciler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayReconciler.java
new file mode 100644
index 0000000000..2a69560f61
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/GatewayReconciler.java
@@ -0,0 +1,513 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.apache.shenyu.k8s.common.ListenerSupport;
+import org.apache.shenyu.k8s.common.StatusMergePatch;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Reconciler for Gateway resources. Reports Accepted/Programmed plus per-listener status
+ * (supportedKinds, attachedRoutes, Accepted/Programmed); a listener is usable only for
+ * plain HTTP on the served port ({@code server.port}), everything else gets the
+ * spec-defined rejection reason.
+ */
+public class GatewayReconciler implements Reconciler {
+
+    private static final Logger LOG = LoggerFactory.getLogger(GatewayReconciler.class);
+
+    private final Lister<DynamicKubernetesObject> gatewayLister;
+
+    private final Lister<DynamicKubernetesObject> gatewayClassLister;
+
+    private final Lister<DynamicKubernetesObject> httpRouteLister;
+
+    private final ShenyuCacheRepository shenyuCacheRepository;
+
+    private final RateLimitingQueue<Request> httpRouteWorkQueue;
+
+    private final RateLimitingQueue<Request> gatewayClassWorkQueue;
+
+    private final ApiClient apiClient;
+
+    /** The port the embedded ShenYu data plane actually listens on ({@code server.port}). */
+    private final int servedPort;
+
+    public GatewayReconciler(final SharedIndexInformer<DynamicKubernetesObject> gatewayInformer,
+                             final SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer,
+                             final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+                             final ShenyuCacheRepository shenyuCacheRepository,
+                             final RateLimitingQueue<Request> httpRouteWorkQueue,
+                             final RateLimitingQueue<Request> gatewayClassWorkQueue,
+                             final ApiClient apiClient,
+                             final int servedPort) {
+        this.gatewayLister = new Lister<>(gatewayInformer.getIndexer());
+        this.gatewayClassLister = new Lister<>(gatewayClassInformer.getIndexer());
+        this.httpRouteLister = new Lister<>(httpRouteInformer.getIndexer());
+        this.shenyuCacheRepository = shenyuCacheRepository;
+        this.httpRouteWorkQueue = httpRouteWorkQueue;
+        this.gatewayClassWorkQueue = gatewayClassWorkQueue;
+        this.apiClient = apiClient;
+        this.servedPort = servedPort;
+    }
+
+    @Override
+    public Result reconcile(final Request request) {
+        LOG.debug("Starting to reconcile gateway {}", request);
+        try {
+            DynamicKubernetesObject gateway = gatewayLister.namespace(request.getNamespace()).get(request.getName());
+
+            if (Objects.isNull(gateway)) {
+                LOG.info("Gateway {} deleted, cleaning associated routes", request);
+                deleteAssociatedRoutes(request.getNamespace(), request.getName());
+                // The class name is unrecoverable after deletion; re-queue all so the drained one drops its finalizer.
+                requeueAllGatewayClasses();
+                return new Result(false);
+            }
+
+            if (!GatewayClassReconciler.isShenyuGateway(gateway, gatewayClassLister)) {
+                if (!previouslyServedByShenyu(gateway, request.getNamespace(), request.getName())) {
+                    LOG.debug("Gateway {} is not managed by ShenYu, skipping", request);
+                    return new Result(false);
+                }
+                LOG.info("Gateway {} is no longer managed by ShenYu, cleaning associated routes", request);
+                deleteAssociatedRoutes(request.getNamespace(), request.getName());
+                // Accepted must not stay True; the flip also re-triggers recovery on class restore.
+                updateGatewayNotAcceptedStatus(gateway);
+                requeueAllGatewayClasses();
+                return new Result(false);
+            }
+
+            boolean wasAccepted = GatewayApiConstants.isConditionTrue(gateway, GatewayApiConstants.CONDITION_ACCEPTED);
+            Long generation = generationOf(gateway);
+            boolean generationObserved = observedGenerationUpToDate(
+                    GatewayApiConstants.findCondition(gateway, GatewayApiConstants.CONDITION_ACCEPTED), generation);
+            JsonObject desiredStatus = buildAcceptedStatus(gateway);
+            preserveTransitionTimes(gateway, desiredStatus);
+            if (!gatewayStatusMatches(gateway, desiredStatus)) {
+                patchGatewayStatus(gateway, desiredStatus);
+            }
+            if (!wasAccepted || !generationObserved) {
+                requeueAffectedHTTPRoutes(request.getNamespace(), request.getName());
+                // Re-queue the class so its finalizer is added without waiting for its resync.
+                enqueueGatewayClass(gateway);
+            }
+
+            LOG.debug("Gateway {} reconciled successfully", request);
+            return new Result(false);
+        } catch (Exception e) {
+            LOG.error("Error reconciling gateway {}, will retry", request, e);
+            return new Result(true);
+        }
+    }
+
+    /** Re-queue bound routes on the Accepted transition and unobserved generations; covers routes created before acceptance. */
+    private void requeueAffectedHTTPRoutes(final String gatewayNamespace, final String gatewayName) {
+        for (DynamicKubernetesObject route : httpRouteLister.list()) {
+            enqueueIfBound(route, gatewayNamespace, gatewayName);
+        }
+    }
+
+    /** Re-queue the Gateway's own class so usage-driven finalizer updates are immediate. */
+    private void enqueueGatewayClass(final DynamicKubernetesObject gateway) {
+        JsonObject spec = JsonFields.getJsonObject(gateway.getRaw(), "spec");
+        String className = JsonFields.getString(spec, "gatewayClassName");
+        if (Objects.nonNull(className)) {
+            gatewayClassWorkQueue.add(new Request("", className));
+        }
+    }
+
+    /** Re-queue every class on Gateway deletion/ownership change, where the affected class is unknowable. */
+    private void requeueAllGatewayClasses() {
+        for (DynamicKubernetesObject gatewayClass : gatewayClassLister.list()) {
+            gatewayClassWorkQueue.add(new Request("", Objects.requireNonNull(gatewayClass.getMetadata()).getName()));
+        }
+    }
+
+    private void enqueueIfBound(final DynamicKubernetesObject route, final String gatewayNamespace,
+                                final String gatewayName) {
+        if (!isBoundToGateway(route, gatewayNamespace, gatewayName)) {
+            return;
+        }
+        httpRouteWorkQueue.add(new Request(route.getMetadata().getNamespace(), route.getMetadata().getName()));
+        LOG.info("Re-queued HTTPRoute {}/{} due to Gateway {}/{} acceptance",
+                route.getMetadata().getNamespace(), route.getMetadata().getName(), gatewayNamespace, gatewayName);
+    }
+
+    private boolean isBoundToGateway(final DynamicKubernetesObject httpRoute,
+                                     final String gatewayNamespace, final String gatewayName) {
+        JsonObject spec = JsonFields.getJsonObject(httpRoute.getRaw(), "spec");
+        JsonArray parentRefs = Objects.isNull(spec) ? null : JsonFields.getJsonArray(spec, "parentRefs");
+        if (Objects.isNull(parentRefs)) {
+            return false;
+        }
+        String routeNamespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace();
+        for (JsonElement element : parentRefs) {
+            JsonObject parentRef = element.getAsJsonObject();
+            String parentName = JsonFields.getString(parentRef, "name");
+            String parentNamespace = Optional.ofNullable(JsonFields.getString(parentRef, "namespace"))
+                    .orElse(routeNamespace);
+            if (gatewayNamespace.equals(parentNamespace) && gatewayName.equals(parentName)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Clean up config of bound routes; routes still attached to another ShenYu Gateway keep theirs. */
+    private void deleteAssociatedRoutes(final String gatewayNamespace, final String gatewayName) {
+        GatewayRouteCache cache = GatewayRouteCache.getInstance();
+        Set<String> routeKeys = cache.getRoutesByGateway(gatewayNamespace, gatewayName);
+        if (CollectionUtils.isEmpty(routeKeys)) {
+            return;
+        }
+        cache.removeRoutesByGateway(gatewayNamespace, gatewayName);
+        for (String routeKey : routeKeys) {
+            String[] parts = routeKey.split("/", 2);
+            if (parts.length != 2) {
+                continue;
+            }
+            String routeNamespace = parts[0];
+            String routeName = parts[1];
+            if (CollectionUtils.isNotEmpty(cache.getGatewaysForRoute(routeNamespace, routeName))) {
+                LOG.info("Route {}/{} still served by another ShenYu Gateway, keeping its config",
+                        routeNamespace, routeName);
+                httpRouteWorkQueue.add(new Request(routeNamespace, routeName));
+                continue;
+            }
+            deleteRouteConfig(routeNamespace, routeName, cache);
+            LOG.info("Deleted ShenYu config for route {}/{} due to Gateway removal", routeNamespace, routeName);
+        }
+    }
+
+    private void deleteRouteConfig(final String routeNamespace, final String routeName,
+                                   final GatewayRouteCache cache) {
+        List<String> selectorIds = cache.removeRouteSelectors(routeNamespace, routeName, PluginEnum.DIVIDE.getName());
+        if (CollectionUtils.isEmpty(selectorIds)) {
+            return;
+        }
+        for (String selectorId : selectorIds) {
+            shenyuCacheRepository.deleteSelectorWithRules(PluginEnum.DIVIDE.getName(), selectorId);
+        }
+    }
+
+    /** Previously served = live route bindings or our own Accepted=True payload (zero-route Gateways). */
+    private boolean previouslyServedByShenyu(final DynamicKubernetesObject gateway, final String namespace, final String name) {
+        return CollectionUtils.isNotEmpty(GatewayRouteCache.getInstance().getRoutesByGateway(namespace, name))
+                || GatewayApiConstants.isConditionAcceptedByShenyu(gateway, GatewayApiConstants.CONDITION_ACCEPTED);
+    }
+
+    private boolean observedGenerationUpToDate(final JsonObject existingCondition, final Long generation) {
+        if (Objects.isNull(generation)) {
+            return true;
+        }
+        return Objects.nonNull(existingCondition)
+                && generation.equals(JsonFields.getLong(existingCondition, "observedGeneration"));
+    }
+
+    /** Keep lastTransitionTime of unchanged conditions, per spec. */
+    private void preserveTransitionTimes(final DynamicKubernetesObject gateway, final JsonObject desiredStatus) {
+        JsonObject existingStatus = JsonFields.getJsonObject(gateway.getRaw(), "status");
+        JsonArray existingConditions = JsonFields.getJsonArray(existingStatus, "conditions");
+        if (Objects.nonNull(existingConditions)) {
+            preserveTransitionTimes(existingConditions, desiredStatus.getAsJsonArray("conditions"));
+        }
+        JsonArray existingListeners = JsonFields.getJsonArray(existingStatus, "listeners");
+        JsonArray desiredListeners = desiredStatus.getAsJsonArray("listeners");
+        if (Objects.isNull(existingListeners) || Objects.isNull(desiredListeners)) {
+            return;
+        }
+        for (JsonElement desiredElement : desiredListeners) {
+            JsonObject desiredListener = desiredElement.getAsJsonObject();
+            JsonArray existingListenerConditions = JsonFields.getJsonArray(
+                    findListenerStatus(existingListeners, JsonFields.getString(desiredListener, "name")), "conditions");
+            if (Objects.nonNull(existingListenerConditions)) {
+                preserveTransitionTimes(existingListenerConditions, desiredListener.getAsJsonArray("conditions"));
+            }
+        }
+    }
+
+    private void preserveTransitionTimes(final JsonArray existingConditions, final JsonArray desiredConditions) {
+        for (JsonElement desiredElement : desiredConditions) {
+            JsonObject desiredCondition = desiredElement.getAsJsonObject();
+            for (JsonElement existingElement : existingConditions) {
+                if (!existingElement.isJsonObject()) {
+                    continue;
+                }
+                JsonObject existingCondition = existingElement.getAsJsonObject();
+                String existingTime = JsonFields.getString(existingCondition, "lastTransitionTime");
+                if (Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+                        && Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))
+                        && Objects.nonNull(existingTime)) {
+                    desiredCondition.addProperty("lastTransitionTime", existingTime);
+                    break;
+                }
+            }
+        }
+    }
+
+    /** Patch Accepted=False on a Gateway the controller no longer manages; no-op when already False. */
+    private void updateGatewayNotAcceptedStatus(final DynamicKubernetesObject gateway) {
+        if (isAcceptedCondition(gateway, "False")) {
+            return;
+        }
+        JsonObject condition = new JsonObject();
+        condition.addProperty("type", GatewayApiConstants.CONDITION_ACCEPTED);
+        condition.addProperty("status", "False");
+        condition.addProperty("reason", GatewayApiConstants.REASON_INVALID);
+        condition.addProperty("message", "GatewayClass is missing, rejected, or not managed by the ShenYu controller");
+        Long generation = generationOf(gateway);
+        if (Objects.nonNull(generation)) {
+            condition.addProperty("observedGeneration", generation);
+        }
+        condition.addProperty("lastTransitionTime", Instant.now().toString());
+
+        JsonArray conditions = new JsonArray();
+        conditions.add(condition);
+        JsonObject statusObj = new JsonObject();
+        statusObj.add("conditions", conditions);
+        patchGatewayStatus(gateway, statusObj);
+    }
+
+    private boolean isAcceptedCondition(final DynamicKubernetesObject gateway, final String status) {
+        JsonObject statusObj = JsonFields.getJsonObject(gateway.getRaw(), "status");
+        JsonArray conditions = JsonFields.getJsonArray(statusObj, "conditions");
+        if (Objects.isNull(conditions)) {
+            return false;
+        }
+        for (JsonElement el : conditions) {
+            if (!el.isJsonObject()) {
+                continue;
+            }
+            JsonObject cond = el.getAsJsonObject();
+            if (GatewayApiConstants.CONDITION_ACCEPTED.equals(JsonFields.getString(cond, "type"))
+                    && status.equals(JsonFields.getString(cond, "status"))) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /** Desired status of an accepted Gateway; attachedRoutes counts only routes bound to that listener. */
+    private JsonObject buildAcceptedStatus(final DynamicKubernetesObject gateway) {
+        Long generation = generationOf(gateway);
+        String namespace = gateway.getMetadata().getNamespace();
+        String name = gateway.getMetadata().getName();
+        GatewayRouteCache cache = GatewayRouteCache.getInstance();
+
+        List<JsonObject> listeners = ListenerSupport.selectListeners(gateway.getRaw(), null);
+        JsonArray listenerStatuses = new JsonArray();
+        boolean anyUsableListener = false;
+        for (JsonObject listener : listeners) {
+            boolean protocolOk = ListenerSupport.isSupportedProtocol(listener);
+            Long port = ListenerSupport.portOf(listener);
+            boolean portOk = Objects.nonNull(port) && port == servedPort;
+            boolean usable = protocolOk && portOk;
+            anyUsableListener |= usable;
+
+            JsonObject listenerStatus = new JsonObject();
+            listenerStatus.addProperty("name", ListenerSupport.nameOf(listener));
+            JsonObject kind = new JsonObject();
+            kind.addProperty("group", GatewayApiConstants.GATEWAY_API_GROUP);
+            kind.addProperty("kind", GatewayApiConstants.HTTP_ROUTE_KIND);
+            JsonArray supportedKinds = new JsonArray();
+            supportedKinds.add(kind);
+            listenerStatus.add("supportedKinds", supportedKinds);
+            Set<String> listenerRoutes = cache.getRoutesByListener(namespace, name, ListenerSupport.nameOf(listener));
+            listenerStatus.addProperty("attachedRoutes", Objects.isNull(listenerRoutes) ? 0 : listenerRoutes.size());
+
+            JsonArray listenerConditions = new JsonArray();
+            if (usable) {
+                listenerConditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "True",
+                        GatewayApiConstants.CONDITION_ACCEPTED, "Listener is accepted", generation));
+                listenerConditions.add(buildCondition(GatewayApiConstants.CONDITION_PROGRAMMED, "True",
+                        GatewayApiConstants.REASON_PROGRAMMED, "Listener is programmed", generation));
+            } else {
+                String reason = protocolOk ? GatewayApiConstants.REASON_PORT_UNAVAILABLE
+                        : GatewayApiConstants.REASON_UNSUPPORTED_PROTOCOL;
+                String message = protocolOk
+                        ? "listener port " + port + " is not served by this gateway (serving " + servedPort + ")"
+                        : "listener protocol " + ListenerSupport.protocolOf(listener) + " is not supported, only HTTP";
+                listenerConditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "False", reason, message, generation));
+                listenerConditions.add(buildCondition(GatewayApiConstants.CONDITION_PROGRAMMED, "False",
+                        "Pending", "Listener is not programmed", generation));
+            }
+            listenerStatus.add("conditions", listenerConditions);
+            listenerStatuses.add(listenerStatus);
+        }
+
+        JsonArray conditions = new JsonArray();
+        conditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "True",
+                GatewayApiConstants.CONDITION_ACCEPTED, "Gateway has been accepted by the ShenYu controller", generation));
+        if (anyUsableListener) {
+            conditions.add(buildCondition(GatewayApiConstants.CONDITION_PROGRAMMED, "True",
+                    GatewayApiConstants.REASON_PROGRAMMED, "Gateway is programmed into the embedded ShenYu data plane", generation));
+        } else {
+            conditions.add(buildCondition(GatewayApiConstants.CONDITION_PROGRAMMED, "False",
+                    GatewayApiConstants.REASON_LISTENERS_NOT_VALID, "No listener with a supported protocol (HTTP) and a served port",
+                    generation));
+        }
+
+        JsonObject status = new JsonObject();
+        status.add("conditions", conditions);
+        status.add("listeners", listenerStatuses);
+        return status;
+    }
+
+    /** Whether the existing status matches desired (ignoring timestamps); keeps the steady state patch-free. */
+    private boolean gatewayStatusMatches(final DynamicKubernetesObject gateway, final JsonObject desiredStatus) {
+        JsonObject existingStatus = JsonFields.getJsonObject(gateway.getRaw(), "status");
+        if (Objects.isNull(existingStatus)) {
+            return false;
+        }
+        if (!conditionsMatch(JsonFields.getJsonArray(existingStatus, "conditions"),
+                desiredStatus.getAsJsonArray("conditions"))) {
+            return false;
+        }
+        JsonArray existingListeners = JsonFields.getJsonArray(existingStatus, "listeners");
+        JsonArray desiredListeners = desiredStatus.getAsJsonArray("listeners");
+        if (Objects.isNull(existingListeners) || existingListeners.size() != desiredListeners.size()) {
+            return false;
+        }
+        for (JsonElement desiredElement : desiredListeners) {
+            JsonObject desiredListener = desiredElement.getAsJsonObject();
+            JsonObject existingListener = findListenerStatus(existingListeners, JsonFields.getString(desiredListener, "name"));
+            if (Objects.isNull(existingListener)
+                    || !Objects.equals(JsonFields.getLong(existingListener, "attachedRoutes"),
+                    JsonFields.getLong(desiredListener, "attachedRoutes"))
+                    || !conditionsMatch(JsonFields.getJsonArray(existingListener, "conditions"),
+                    desiredListener.getAsJsonArray("conditions"))) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private JsonObject findListenerStatus(final JsonArray listeners, final String name) {
+        for (JsonElement element : listeners) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            JsonObject listener = element.getAsJsonObject();
+            if (Objects.equals(JsonFields.getString(listener, "name"), name)) {
+                return listener;
+            }
+        }
+        return null;
+    }
+
+    /** Compare by (type, status, observedGeneration) and reason for False; ignore timestamps. */
+    private boolean conditionsMatch(final JsonArray existing, final JsonArray desired) {
+        if (Objects.isNull(existing) || Objects.isNull(desired) || existing.size() < desired.size()) {
+            return false;
+        }
+        for (JsonElement desiredElement : desired) {
+            JsonObject desiredCondition = desiredElement.getAsJsonObject();
+            boolean found = false;
+            for (JsonElement existingElement : existing) {
+                if (!existingElement.isJsonObject()) {
+                    continue;
+                }
+                JsonObject existingCondition = existingElement.getAsJsonObject();
+                boolean reasonMatters = "False".equals(JsonFields.getString(desiredCondition, "status"));
+                boolean reasonMatches = !reasonMatters || Objects.equals(
+                        JsonFields.getString(existingCondition, "reason"), JsonFields.getString(desiredCondition, "reason"));
+                if (Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+                        && Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))
+                        && Objects.equals(JsonFields.getLong(existingCondition, "observedGeneration"),
+                        JsonFields.getLong(desiredCondition, "observedGeneration"))
+                        && reasonMatches) {
+                    found = true;
+                    break;
+                }
+            }
+            if (!found) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private static JsonObject buildCondition(final String type, final String status, final String reason,
+                                             final String message, final Long observedGeneration) {
+        JsonObject condition = new JsonObject();
+        condition.addProperty("type", type);
+        condition.addProperty("status", status);
+        condition.addProperty("reason", reason);
+        condition.addProperty("message", message);
+        if (Objects.nonNull(observedGeneration)) {
+            condition.addProperty("observedGeneration", observedGeneration);
+        }
+        condition.addProperty("lastTransitionTime", Instant.now().toString());
+        return condition;
+    }
+
+    private static Long generationOf(final DynamicKubernetesObject gateway) {
+        return JsonFields.getLong(JsonFields.getJsonObject(gateway.getRaw(), "metadata"), "generation");
+    }
+
+    /** Merge-patch the Gateway /status subresource, preserving conditions owned by other controllers. */
+    private void patchGatewayStatus(final DynamicKubernetesObject gateway, final JsonObject statusObj) {
+        try {
+            final String namespace = gateway.getMetadata().getNamespace();
+            final String name = gateway.getMetadata().getName();
+
+            JsonObject body = new JsonObject();
+            body.add("status", statusObj);
+            body.addProperty("kind", GatewayApiConstants.GATEWAY_KIND);
+            body.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION);
+
+            JsonObject metadata = new JsonObject();
+            metadata.addProperty("name", name);
+            metadata.addProperty("namespace", namespace);
+            body.add("metadata", metadata);
+
+            String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+                    + "/namespaces/" + namespace + "/gateways/" + name + "/status";
+
+            StatusMergePatch.patch(apiClient, path, body);
+            LOG.info("Updated Gateway {}/{} status", namespace, name);
+        } catch (Exception e) {
+            LOG.warn("Failed to update Gateway status, will retry on next resync", e);
+        }
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HTTPRouteReconciler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HTTPRouteReconciler.java
new file mode 100644
index 0000000000..ab3b5c2485
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HTTPRouteReconciler.java
@@ -0,0 +1,652 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.commons.collections4.CollectionUtils;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.IngressConfiguration;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.apache.shenyu.k8s.common.ListenerSupport;
+import org.apache.shenyu.k8s.common.ShenyuMemoryConfig;
+import org.apache.shenyu.k8s.common.StatusMergePatch;
+import org.apache.shenyu.k8s.parser.HttpRouteParser;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Reconciler for HTTPRoute resources. Every parentRef is evaluated and reported in
+ * status.parents with spec-defined rejection reasons, except parentRefs of foreign
+ * controllers. Cross-namespace attachment is authorized by allowedRoutes only;
+ * ReferenceGrant governs cross-namespace backendRefs (validated by {@link HttpRouteParser}).
+ */
+public class HTTPRouteReconciler implements Reconciler {
+
+    private static final Logger LOG = LoggerFactory.getLogger(HTTPRouteReconciler.class);
+
+    private final Lister<DynamicKubernetesObject> httpRouteLister;
+
+    private final Lister<DynamicKubernetesObject> gatewayLister;
+
+    private final Lister<DynamicKubernetesObject> gatewayClassLister;
+
+    private final HttpRouteParser httpRouteParser;
+
+    private final ShenyuCacheRepository shenyuCacheRepository;
+
+    private final ApiClient apiClient;
+
+    /** The port the embedded ShenYu data plane actually listens on ({@code server.port}); route attachment is allowed only on listeners the gateway really serves. */
+    private final int servedPort;
+
+    public HTTPRouteReconciler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+                               final SharedIndexInformer<DynamicKubernetesObject> gatewayInformer,
+                               final SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer,
+                               final HttpRouteParser httpRouteParser,
+                               final ShenyuCacheRepository shenyuCacheRepository,
+                               final ApiClient apiClient,
+                               final int servedPort) {
+        this.httpRouteLister = new Lister<>(httpRouteInformer.getIndexer());
+        this.gatewayLister = new Lister<>(gatewayInformer.getIndexer());
+        this.gatewayClassLister = new Lister<>(gatewayClassInformer.getIndexer());
+        this.httpRouteParser = httpRouteParser;
+        this.shenyuCacheRepository = shenyuCacheRepository;
+        this.apiClient = apiClient;
+        this.servedPort = servedPort;
+    }
+
+    @Override
+    public Result reconcile(final Request request) {
+        String namespace = request.getNamespace();
+        String routeName = request.getName();
+        LOG.debug("Starting to reconcile HTTPRoute {}/{}", namespace, routeName);
+
+        DynamicKubernetesObject httpRoute = httpRouteLister.namespace(namespace).get(routeName);
+        if (Objects.isNull(httpRoute)) {
+            deleteConfig(namespace, routeName);
+            return new Result(false);
+        }
+
+        List<ParentDecision> decisions = evaluateParents(httpRoute);
+        if (decisions.isEmpty()) {
+            // Not attached to any ShenYu Gateway: drop config and our status entries.
+            deleteConfig(namespace, routeName);
+            removeShenyuParentStatus(httpRoute, namespace, routeName);
+            return new Result(false);
+        }
+
+        List<ParentDecision> accepted = new ArrayList<>();
+        for (ParentDecision decision : decisions) {
+            if (decision.accepted) {
+                accepted.add(decision);
+            }
+        }
+
+        ShenyuMemoryConfig config = null;
+        if (accepted.isEmpty()) {
+            // All ShenYu parents rejected the attachment: clean up but still report status.
+            deleteConfig(namespace, routeName);
+        } else {
+            config = httpRouteParser.parse(httpRoute, effectiveHostnames(accepted));
+            if (config.isHasUnsupportedFilters()) {
+                deleteConfig(namespace, routeName);
+            } else {
+                GatewayRouteCache cache = GatewayRouteCache.getInstance();
+                List<String> newSelectorIds = selectorIdsOf(config);
+                // read the previous snapshot before putRouteSelectors overwrites it
+                List<String> oldSelectorIds = Objects.requireNonNullElse(
+                        cache.getRouteSelectors(namespace, routeName, PluginEnum.DIVIDE.getName()), List.of());
+                applyConfig(config, namespace, routeName);
+                deleteStaleSelectors(namespace, routeName, oldSelectorIds, newSelectorIds);
+                cache.putRouteSelectors(namespace, routeName, PluginEnum.DIVIDE.getName(), newSelectorIds);
+                rebindGateways(cache, namespace, routeName, accepted);
+            }
+        }
+
+        updateHTTPRouteStatus(httpRoute, decisions, config, namespace, routeName);
+        return new Result(false);
+    }
+
+    private List<String> selectorIdsOf(final ShenyuMemoryConfig config) {
+        List<String> ids = new ArrayList<>();
+        for (IngressConfiguration rc : config.getRouteConfigList()) {
+            ids.add(rc.getSelectorData().getId());
+        }
+        return ids;
+    }
+
+    /** Union of per-parent hostname intersections; one accepting parent with no hostnames makes the route host-agnostic overall. */
+    private List<String> effectiveHostnames(final List<ParentDecision> accepted) {
+        Set<String> union = new LinkedHashSet<>();
+        for (ParentDecision decision : accepted) {
+            if (decision.hostnames.isEmpty()) {
+                return List.of();
+            }
+            union.addAll(decision.hostnames);
+        }
+        return new ArrayList<>(union);
+    }
+
+    /** Rebuild route→gateway listener bindings from accepted parents; they drive attachedRoutes counts. */
+    private void rebindGateways(final GatewayRouteCache cache, final String namespace, final String routeName,
+                                final List<ParentDecision> accepted) {
+        cache.removeRouteGatewayBinding(namespace, routeName);
+        for (ParentDecision decision : accepted) {
+            cache.bindRouteToGateway(decision.parentNamespace, decision.parentName, decision.listenerNames,
+                    namespace, routeName);
+        }
+    }
+
+    /** Evaluate every parentRef; foreign kinds/groups and other controllers' Gateways are skipped silently. */
+    private List<ParentDecision> evaluateParents(final DynamicKubernetesObject httpRoute) {
+        String routeNamespace = Objects.requireNonNull(httpRoute.getMetadata()).getNamespace();
+        JsonObject spec = JsonFields.getJsonObject(httpRoute.getRaw(), "spec");
+        JsonArray parentRefs = JsonFields.getJsonArray(spec, "parentRefs");
+        List<String> routeHostnames = readStringList(JsonFields.getJsonArray(spec, "hostnames"));
+        List<ParentDecision> decisions = new ArrayList<>();
+        if (Objects.isNull(parentRefs)) {
+            return decisions;
+        }
+        for (JsonElement element : parentRefs) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            JsonObject parentRef = element.getAsJsonObject();
+            String group = JsonFields.getString(parentRef, "group");
+            String kind = JsonFields.getString(parentRef, "kind");
+            if ((Objects.nonNull(group) && !GatewayApiConstants.GATEWAY_API_GROUP.equals(group))
+                    || (Objects.nonNull(kind) && !GatewayApiConstants.GATEWAY_KIND.equals(kind))) {
+                continue;
+            }
+            String parentName = JsonFields.getString(parentRef, "name");
+            if (Objects.isNull(parentName)) {
+                continue;
+            }
+            String parentNamespace = JsonFields.getString(parentRef, "namespace");
+            if (Objects.isNull(parentNamespace)) {
+                parentNamespace = routeNamespace;
+            }
+            DynamicKubernetesObject gateway = gatewayLister.namespace(parentNamespace).get(parentName);
+            if (Objects.isNull(gateway)) {
+                decisions.add(ParentDecision.rejected(parentRef, parentNamespace, parentName,
+                        GatewayApiConstants.REASON_NO_MATCHING_PARENT,
+                        "Gateway " + parentNamespace + "/" + parentName + " does not exist"));
+                continue;
+            }
+            if (!isShenyuGateway(gateway)) {
+                continue;
+            }
+            // allowedRoutes authorizes cross-ns parentRefs; ReferenceGrant never applies to parentRefs per spec.
+            String sectionName = JsonFields.getString(parentRef, "sectionName");
+            Long parentPort = JsonFields.getLong(parentRef, "port");
+            decisions.add(evaluateListeners(gateway, sectionName, parentPort, routeNamespace, parentNamespace,
+                    parentName, routeHostnames, parentRef));
+        }
+        return decisions;
+    }
+
+    /** Attach when at least one selected listener is usable, allowed and hostname-intersecting. */
+    private ParentDecision evaluateListeners(final DynamicKubernetesObject gateway, final String sectionName,
+                                             final Long parentPort, final String routeNamespace,
+                                             final String parentNamespace, final String parentName,
+                                             final List<String> routeHostnames, final JsonObject parentRef) {
+        List<JsonObject> selected = ListenerSupport.selectListeners(gateway.getRaw(), sectionName, parentPort);
+        if (selected.isEmpty()) {
+            String message;
+            if (Objects.nonNull(sectionName)) {
+                message = "no listener named '" + sectionName + "' on Gateway " + parentNamespace + "/" + parentName;
+                if (Objects.nonNull(parentPort)) {
+                    message = message + " serving port " + parentPort;
+                }
+            } else if (Objects.nonNull(parentPort)) {
+                message = "Gateway " + parentNamespace + "/" + parentName + " has no listener on port " + parentPort;
+            } else {
+                message = "Gateway " + parentNamespace + "/" + parentName + " has no listeners";
+            }
+            return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+                    GatewayApiConstants.REASON_UNSUPPORTED_VALUE, message);
+        }
+        Set<String> effective = new LinkedHashSet<>();
+        Set<String> matchedListeners = new LinkedHashSet<>();
+        boolean anyMatched = false;
+        boolean unsupportedFrom = false;
+        boolean notPermitted = false;
+        boolean hostnameMismatch = false;
+        for (JsonObject listener : selected) {
+            // Must mirror the Gateway reconciler's listener usability or status and data plane diverge.
+            if (!ListenerSupport.isSupportedProtocol(listener) || !ListenerSupport.servesPort(listener, servedPort)) {
+                continue;
+            }
+            if (ListenerSupport.usesUnsupportedFrom(listener)) {
+                unsupportedFrom = true;
+                continue;
+            }
+            if (!ListenerSupport.allowsNamespace(listener, routeNamespace, parentNamespace)
+                    || !ListenerSupport.allowsKind(listener)) {
+                notPermitted = true;
+                continue;
+            }
+            List<String> intersect = ListenerSupport.intersectHostnames(ListenerSupport.hostnameOf(listener), routeHostnames);
+            if (Objects.isNull(intersect)) {
+                hostnameMismatch = true;
+                continue;
+            }
+            // an empty intersection is still a match: no hostnames on either side means "any host"
+            anyMatched = true;
+            matchedListeners.add(ListenerSupport.nameOf(listener));
+            effective.addAll(intersect);
+        }
+        if (anyMatched) {
+            return ParentDecision.accepted(parentRef, parentNamespace, parentName, new ArrayList<>(effective),
+                    matchedListeners);
+        }
+        if (unsupportedFrom) {
+            return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+                    GatewayApiConstants.REASON_UNSUPPORTED_VALUE,
+                    "allowedRoutes with from=Selector is not supported by the ShenYu controller");
+        }
+        if (notPermitted) {
+            return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+                    GatewayApiConstants.REASON_REF_NOT_PERMITTED,
+                    "listener allowedRoutes policy does not permit routes from namespace " + routeNamespace);
+        }
+        if (hostnameMismatch) {
+            return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+                    GatewayApiConstants.REASON_NO_MATCHING_LISTENER_HOSTNAME,
+                    "route hostnames do not intersect any listener hostname");
+        }
+        return ParentDecision.rejected(parentRef, parentNamespace, parentName,
+                GatewayApiConstants.REASON_UNSUPPORTED_VALUE,
+                "no listener with a supported protocol (HTTP) on a served port (serving " + servedPort + ")");
+    }
+
+    private List<String> readStringList(final JsonArray array) {
+        List<String> result = new ArrayList<>();
+        if (Objects.nonNull(array)) {
+            for (JsonElement element : array) {
+                if (element.isJsonPrimitive()) {
+                    result.add(element.getAsString());
+                }
+            }
+        }
+        return result;
+    }
+
+    private boolean isShenyuGateway(final DynamicKubernetesObject gateway) {
+        return GatewayClassReconciler.isShenyuGateway(gateway, gatewayClassLister);
+    }
+
+    private void applyConfig(final ShenyuMemoryConfig config, final String namespace, final String routeName) {
+        for (IngressConfiguration routeConfig : config.getRouteConfigList()) {
+            shenyuCacheRepository.saveOrUpdateSelectorData(routeConfig.getSelectorData());
+            if (CollectionUtils.isNotEmpty(routeConfig.getRuleDataList())) {
+                shenyuCacheRepository.saveOrUpdateRuleData(routeConfig.getRuleDataList().get(0));
+            }
+        }
+        LOG.debug("HTTPRoute {}/{}: applied {} selector(s)", namespace, routeName, config.getRouteConfigList().size());
+    }
+
+    /** Delete selectors gone from the current parse; runs after the new config applies so a live interval never goes unmatched. */
+    private void deleteStaleSelectors(final String namespace, final String routeName,
+                                      final List<String> oldSelectorIds, final List<String> newSelectorIds) {
+        Set<String> stale = new LinkedHashSet<>(oldSelectorIds);
+        stale.removeAll(newSelectorIds);
+        for (String selectorId : stale) {
+            shenyuCacheRepository.deleteSelectorWithRules(PluginEnum.DIVIDE.getName(), selectorId);
+            LOG.info("HTTPRoute {}/{}: removed stale divide selector {}", namespace, routeName, selectorId);
+        }
+    }
+
+    private void deleteConfig(final String namespace, final String routeName) {
+        GatewayRouteCache cache = GatewayRouteCache.getInstance();
+        List<String> selectorIds = cache.removeRouteSelectors(namespace, routeName, PluginEnum.DIVIDE.getName());
+        if (Objects.nonNull(selectorIds)) {
+            for (String selectorId : selectorIds) {
+                shenyuCacheRepository.deleteSelectorWithRules(PluginEnum.DIVIDE.getName(), selectorId);
+                LOG.info("HTTPRoute {}/{}: deleted divide selector {}", namespace, routeName, selectorId);
+            }
+        }
+        cache.removeRouteGatewayBinding(namespace, routeName);
+    }
+
+    private void updateHTTPRouteStatus(final DynamicKubernetesObject httpRoute, final List<ParentDecision> decisions,
+                                       final ShenyuMemoryConfig config, final String namespace, final String routeName) {
+        Long generation = JsonFields.getLong(JsonFields.getJsonObject(httpRoute.getRaw(), "metadata"), "generation");
+        JsonArray desiredParents = new JsonArray();
+        for (ParentDecision decision : decisions) {
+            desiredParents.add(buildParentStatus(decision, config, generation));
+        }
+
+        JsonObject raw = httpRoute.getRaw();
+        JsonObject currentStatus = JsonFields.getJsonObject(raw, "status");
+        if (Objects.nonNull(currentStatus)) {
+            JsonArray existingParents = JsonFields.getJsonArray(currentStatus, "parents");
+            retainForeignParentEntries(existingParents, desiredParents);
+            if (existingStatusMatches(existingParents, desiredParents)) {
+                return;
+            }
+            preserveTransitionTimes(existingParents, desiredParents);
+        }
+
+        JsonObject status = new JsonObject();
+        status.add("parents", desiredParents);
+        sendStatusPatch(httpRoute, namespace, routeName, status);
+    }
+
+    private JsonObject buildParentStatus(final ParentDecision decision, final ShenyuMemoryConfig config,
+                                         final Long generation) {
+        JsonObject parentRefStatus = new JsonObject();
+        parentRefStatus.addProperty("group", GatewayApiConstants.GATEWAY_API_GROUP);
+        parentRefStatus.addProperty("kind", GatewayApiConstants.GATEWAY_KIND);
+        parentRefStatus.addProperty("namespace", decision.parentNamespace);
+        parentRefStatus.addProperty("name", decision.parentName);
+        String sectionName = JsonFields.getString(decision.parentRef, "sectionName");
+        if (Objects.nonNull(sectionName)) {
+            parentRefStatus.addProperty("sectionName", sectionName);
+        }
+        Long parentPort = JsonFields.getLong(decision.parentRef, "port");
+        if (Objects.nonNull(parentPort)) {
+            parentRefStatus.addProperty("port", parentPort);
+        }
+
+        JsonObject parent = new JsonObject();
+        parent.add("parentRef", parentRefStatus);
+        parent.addProperty("controllerName", GatewayApiConstants.SHENYU_CONTROLLER_NAME);
+        parent.add("conditions", buildStatusConditions(decision, config, generation));
+        return parent;
+    }
+
+    private JsonArray buildStatusConditions(final ParentDecision decision, final ShenyuMemoryConfig config,
+                                            final Long generation) {
+        JsonArray conditions = new JsonArray();
+
+        boolean unsupportedFilters = Objects.nonNull(config) && config.isHasUnsupportedFilters();
+        if (!decision.accepted) {
+            conditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "False", decision.reason,
+                    decision.message, generation));
+        } else if (unsupportedFilters) {
+            conditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "False",
+                    GatewayApiConstants.REASON_UNSUPPORTED_VALUE,
+                    "route rules declare filters which are not supported by ShenYu", generation));
+        } else {
+            conditions.add(buildCondition(GatewayApiConstants.CONDITION_ACCEPTED, "True", "Accepted",
+                    "HTTPRoute is accepted by the ShenYu Gateway", generation));
+        }
+
+        boolean resolved = Objects.isNull(config) || config.isAllBackendsResolved();
+        if (resolved) {
+            conditions.add(buildCondition(GatewayApiConstants.CONDITION_RESOLVED_REFS, "True", "ResolvedRefs",
+                    "All references have been resolved", generation));
+        } else {
+            String reason = Objects.nonNull(config.getUnresolvedReason())
+                    ? config.getUnresolvedReason() : GatewayApiConstants.REASON_BACKEND_NOT_FOUND;
+            conditions.add(buildCondition(GatewayApiConstants.CONDITION_RESOLVED_REFS, "False", reason,
+                    "Some services were not found or not permitted", generation));
+        }
+        return conditions;
+    }
+
+    private static JsonObject buildCondition(final String type, final String status, final String reason,
+                                             final String message, final Long observedGeneration) {
+        JsonObject condition = new JsonObject();
+        condition.addProperty("type", type);
+        condition.addProperty("status", status);
+        condition.addProperty("reason", reason);
+        condition.addProperty("message", message);
+        if (Objects.nonNull(observedGeneration)) {
+            condition.addProperty("observedGeneration", observedGeneration);
+        }
+        condition.addProperty("lastTransitionTime", java.time.Instant.now().toString());
+        return condition;
+    }
+
+    /** No-op patches must be skipped: each bumps resourceVersion and re-enqueues the route forever. */
+    private boolean existingStatusMatches(final JsonArray existingParents, final JsonArray desiredParents) {
+        if (Objects.isNull(existingParents) || existingParents.size() != desiredParents.size()) {
+            return false;
+        }
+        List<JsonObject> unmatched = new ArrayList<>();
+        for (JsonElement element : existingParents) {
+            if (element.isJsonObject()) {
+                unmatched.add(element.getAsJsonObject());
+            }
+        }
+        for (JsonElement desiredElement : desiredParents) {
+            JsonObject desired = desiredElement.getAsJsonObject();
+            boolean found = false;
+            for (int i = 0; i < unmatched.size(); i++) {
+                JsonObject existing = unmatched.get(i);
+                if (sameParentRef(JsonFields.getJsonObject(existing, "parentRef"),
+                        JsonFields.getJsonObject(desired, "parentRef"))
+                        && Objects.equals(JsonFields.getString(desired, "controllerName"),
+                        JsonFields.getString(existing, "controllerName"))
+                        && conditionsMatch(JsonFields.getJsonArray(existing, "conditions"),
+                        JsonFields.getJsonArray(desired, "conditions"))) {
+                    unmatched.remove(i);
+                    found = true;
+                    break;
+                }
+            }
+            if (!found) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    private boolean sameParentRef(final JsonObject existing, final JsonObject desired) {
+        if (Objects.isNull(existing) || Objects.isNull(desired)) {
+            return false;
+        }
+        return Objects.equals(JsonFields.getString(existing, "namespace"), JsonFields.getString(desired, "namespace"))
+                && Objects.equals(JsonFields.getString(existing, "name"), JsonFields.getString(desired, "name"))
+                && Objects.equals(JsonFields.getString(existing, "sectionName"), JsonFields.getString(desired, "sectionName"))
+                && Objects.equals(JsonFields.getLong(existing, "port"), JsonFields.getLong(desired, "port"));
+    }
+
+    private boolean conditionsMatch(final JsonArray existing, final JsonArray desired) {
+        if (Objects.isNull(existing) || Objects.isNull(desired) || existing.size() != desired.size()) {
+            return false;
+        }
+        for (JsonElement desiredElement : desired) {
+            JsonObject desiredCondition = desiredElement.getAsJsonObject();
+            boolean found = false;
+            for (JsonElement existingElement : existing) {
+                JsonObject existingCondition = existingElement.getAsJsonObject();
+                boolean reasonMatters = "False".equals(JsonFields.getString(desiredCondition, "status"));
+                boolean reasonMatches = !reasonMatters || Objects.equals(
+                        JsonFields.getString(existingCondition, "reason"), JsonFields.getString(desiredCondition, "reason"));
+                if (Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+                        && Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))
+                        && Objects.equals(JsonFields.getLong(existingCondition, "observedGeneration"),
+                        JsonFields.getLong(desiredCondition, "observedGeneration"))
+                        && reasonMatches) {
+                    found = true;
+                    break;
+                }
+            }
+            if (!found) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /** Keep lastTransitionTime of unchanged conditions; it advances only on real transitions. */
+    private void preserveTransitionTimes(final JsonArray existingParents, final JsonArray desiredParents) {
+        for (JsonElement desiredElement : desiredParents) {
+            JsonObject desired = desiredElement.getAsJsonObject();
+            JsonObject desiredRef = JsonFields.getJsonObject(desired, "parentRef");
+            JsonArray desiredConditions = JsonFields.getJsonArray(desired, "conditions");
+            for (JsonElement existingElement : existingParents) {
+                JsonObject existing = existingElement.getAsJsonObject();
+                if (!GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(JsonFields.getString(existing, "controllerName"))) {
+                    continue;
+                }
+                if (!sameParentRef(JsonFields.getJsonObject(existing, "parentRef"), desiredRef)) {
+                    continue;
+                }
+                JsonArray existingConditions = JsonFields.getJsonArray(existing, "conditions");
+                if (Objects.isNull(existingConditions) || Objects.isNull(desiredConditions)) {
+                    continue;
+                }
+                for (JsonElement desiredConditionElement : desiredConditions) {
+                    JsonObject desiredCondition = desiredConditionElement.getAsJsonObject();
+                    for (JsonElement existingConditionElement : existingConditions) {
+                        JsonObject existingCondition = existingConditionElement.getAsJsonObject();
+                        if (Objects.equals(JsonFields.getString(existingCondition, "type"), JsonFields.getString(desiredCondition, "type"))
+                                && Objects.equals(JsonFields.getString(existingCondition, "status"), JsonFields.getString(desiredCondition, "status"))
+                                && Objects.nonNull(JsonFields.getString(existingCondition, "lastTransitionTime"))) {
+                            desiredCondition.addProperty("lastTransitionTime",
+                                    JsonFields.getString(existingCondition, "lastTransitionTime"));
+                            break;
+                        }
+                    }
+                }
+            }
+        }
+    }
+
+    /** Foreign parents must ride in the patch body: merge patch replaces the array wholesale. */
+    private void retainForeignParentEntries(final JsonArray existingParents, final JsonArray desiredParents) {
+        if (Objects.isNull(existingParents)) {
+            return;
+        }
+        for (JsonElement element : existingParents) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            JsonObject parent = element.getAsJsonObject();
+            if (!GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(JsonFields.getString(parent, "controllerName"))) {
+                desiredParents.add(element);
+            }
+        }
+    }
+
+    /** Remove ShenYu's parents entries, preserving other controllers'. */
+    private void removeShenyuParentStatus(final DynamicKubernetesObject httpRoute, final String namespace,
+                                          final String routeName) {
+        JsonObject status = JsonFields.getJsonObject(httpRoute.getRaw(), "status");
+        JsonArray existingParents = JsonFields.getJsonArray(status, "parents");
+        if (Objects.isNull(existingParents)) {
+            return;
+        }
+        JsonArray kept = new JsonArray();
+        boolean hasShenyuEntry = false;
+        for (JsonElement element : existingParents) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            JsonObject parent = element.getAsJsonObject();
+            if (GatewayApiConstants.SHENYU_CONTROLLER_NAME.equals(JsonFields.getString(parent, "controllerName"))) {
+                hasShenyuEntry = true;
+            } else {
+                kept.add(parent);
+            }
+        }
+        if (!hasShenyuEntry) {
+            return;
+        }
+        JsonObject newStatus = new JsonObject();
+        newStatus.add("parents", kept);
+        sendStatusPatch(httpRoute, namespace, routeName, newStatus);
+    }
+
+    private void sendStatusPatch(final DynamicKubernetesObject httpRoute, final String namespace,
+                                 final String routeName, final JsonObject status) {
+        JsonObject patch = new JsonObject();
+        patch.addProperty("apiVersion", GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION);
+        patch.addProperty("kind", GatewayApiConstants.HTTP_ROUTE_KIND);
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", namespace);
+        metadata.addProperty("name", routeName);
+        patch.add("metadata", metadata);
+        patch.add("status", status);
+
+        String path = "/apis/" + GatewayApiConstants.GATEWAY_API_GROUP + "/" + GatewayApiConstants.GATEWAY_API_VERSION
+                + "/namespaces/" + namespace + "/httproutes/" + routeName + "/status";
+        try {
+            StatusMergePatch.patch(apiClient, path, patch);
+            LOG.debug("Updated status of HTTPRoute {}/{}", namespace, routeName);
+        } catch (ApiException e) {
+            LOG.warn("Failed to update status of HTTPRoute {}/{}: {}", namespace, routeName, e.getMessage());
+        }
+    }
+
+    /** One parentRef verdict: acceptance with hostnames/listeners, or rejection with a spec reason. */
+    private static final class ParentDecision {
+
+        private final JsonObject parentRef;
+
+        private final String parentNamespace;
+
+        private final String parentName;
+
+        private final boolean accepted;
+
+        private final String reason;
+
+        private final String message;
+
+        private final List<String> hostnames;
+
+        private final Set<String> listenerNames;
+
+        private ParentDecision(final JsonObject parentRef, final String parentNamespace, final String parentName,
+                               final boolean accepted, final String reason, final String message,
+                               final List<String> hostnames, final Set<String> listenerNames) {
+            this.parentRef = parentRef;
+            this.parentNamespace = parentNamespace;
+            this.parentName = parentName;
+            this.accepted = accepted;
+            this.reason = reason;
+            this.message = message;
+            this.hostnames = hostnames;
+            this.listenerNames = listenerNames;
+        }
+
+        static ParentDecision accepted(final JsonObject parentRef, final String parentNamespace, final String parentName,
+                                       final List<String> hostnames, final Set<String> listenerNames) {
+            return new ParentDecision(parentRef, parentNamespace, parentName, true, null, null, hostnames, listenerNames);
+        }
+
+        static ParentDecision rejected(final JsonObject parentRef, final String parentNamespace, final String parentName,
+                                       final String reason, final String message) {
+            return new ParentDecision(parentRef, parentNamespace, parentName, false, reason, message, List.of(), Set.of());
+        }
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteBackendHandler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteBackendHandler.java
new file mode 100644
index 0000000000..cb76a107df
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteBackendHandler.java
@@ -0,0 +1,120 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.openapi.models.V1ObjectMeta;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.JsonFields;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/**
+ * Shared machinery of the backend event handlers (Endpoints, Service): both resolve
+ * affected HTTPRoutes through the informer index on referenced Service ("namespace/name",
+ * shared with the Endpoints object) instead of scanning the cache. The queue deduplicates
+ * and the reconciler re-checks binding, so extra enqueues are harmless.
+ */
+abstract class HttpRouteBackendHandler {
+
+    /** Index key: the Services ("namespace/name") each route's backendRefs reference. */
+    static final String BACKEND_SERVICE_INDEX = "backendService";
+
+    private final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer;
+
+    private final RateLimitingQueue<Request> httpRouteWorkQueue;
+
+    HttpRouteBackendHandler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+                            final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        this.httpRouteInformer = httpRouteInformer;
+        this.httpRouteWorkQueue = httpRouteWorkQueue;
+        // Indexers must be registered before the informer starts; the handler beans are
+        // created (and depended on by the controller manager) before informers run. Both
+        // backend handlers share one index, so whichever registers first wins.
+        if (!httpRouteInformer.getIndexer().getIndexers().containsKey(BACKEND_SERVICE_INDEX)) {
+            this.httpRouteInformer.addIndexers(Map.of(BACKEND_SERVICE_INDEX, HttpRouteBackendHandler::indexBackendServices));
+        }
+    }
+
+    static List<String> indexBackendServices(final DynamicKubernetesObject route) {
+        if (Objects.isNull(route.getMetadata())) {
+            return Collections.emptyList();
+        }
+        String routeNamespace = route.getMetadata().getNamespace();
+        JsonObject spec = JsonFields.getJsonObject(route.getRaw(), "spec");
+        JsonElement rulesElement = Objects.isNull(spec) ? null : JsonFields.getJsonArray(spec, "rules");
+        if (Objects.isNull(rulesElement)) {
+            return Collections.emptyList();
+        }
+        List<String> keys = new ArrayList<>();
+        for (JsonElement ruleElement : rulesElement.getAsJsonArray()) {
+            JsonObject rule = ruleElement.getAsJsonObject();
+            JsonElement refsElement = JsonFields.getJsonArray(rule, "backendRefs");
+            if (Objects.isNull(refsElement)) {
+                continue;
+            }
+            for (JsonElement refElement : refsElement.getAsJsonArray()) {
+                JsonObject ref = refElement.getAsJsonObject();
+                if (!GatewayApiConstants.isServiceRef(ref)) {
+                    continue;
+                }
+                String name = JsonFields.getString(ref, "name");
+                String namespace = JsonFields.getString(ref, "namespace");
+                if (Objects.isNull(name)) {
+                    continue;
+                }
+                keys.add((Objects.isNull(namespace) ? routeNamespace : namespace) + "/" + name);
+            }
+        }
+        return keys;
+    }
+
+    /** Null-safe accessors so each event callback stays a one-liner. */
+    static String namespaceOf(final V1ObjectMeta metadata) {
+        return Objects.isNull(metadata) ? null : metadata.getNamespace();
+    }
+
+    static String nameOf(final V1ObjectMeta metadata) {
+        return Objects.isNull(metadata) ? null : metadata.getName();
+    }
+
+    /**
+     * Enqueue every HTTPRoute whose backendRefs target the Service {@code namespace/name}.
+     * Null coordinates (malformed event object) enqueue nothing.
+     */
+    final void enqueueAffectedRoutes(final String namespace, final String name) {
+        if (Objects.isNull(namespace) || Objects.isNull(name)) {
+            return;
+        }
+        for (DynamicKubernetesObject route : httpRouteInformer.getIndexer().byIndex(BACKEND_SERVICE_INDEX, namespace + "/" + name)) {
+            if (Objects.isNull(route.getMetadata())) {
+                continue;
+            }
+            httpRouteWorkQueue.add(new Request(route.getMetadata().getNamespace(), route.getMetadata().getName()));
+        }
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteEndpointsHandler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteEndpointsHandler.java
new file mode 100644
index 0000000000..c27638c8e9
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteEndpointsHandler.java
@@ -0,0 +1,49 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.ResourceEventHandler;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+
+/** Bridges Endpoints events to HTTPRoute reconciliation; see {@link HttpRouteBackendHandler}. */
+public final class HttpRouteEndpointsHandler extends HttpRouteBackendHandler implements ResourceEventHandler<V1Endpoints> {
+
+    public HttpRouteEndpointsHandler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+                                     final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        super(httpRouteInformer, httpRouteWorkQueue);
+    }
+
+    @Override
+    public void onAdd(final V1Endpoints endpoints) {
+        enqueueAffectedRoutes(namespaceOf(endpoints.getMetadata()), nameOf(endpoints.getMetadata()));
+    }
+
+    @Override
+    public void onUpdate(final V1Endpoints oldEndpoints, final V1Endpoints newEndpoints) {
+        enqueueAffectedRoutes(namespaceOf(newEndpoints.getMetadata()), nameOf(newEndpoints.getMetadata()));
+    }
+
+    @Override
+    public void onDelete(final V1Endpoints endpoints, final boolean unknownState) {
+        enqueueAffectedRoutes(namespaceOf(endpoints.getMetadata()), nameOf(endpoints.getMetadata()));
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteServiceHandler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteServiceHandler.java
new file mode 100644
index 0000000000..f40dcf11f0
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/HttpRouteServiceHandler.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.ResourceEventHandler;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+
+/**
+ * Bridges Service events to route reconciliation: port/targetPort edits never touch
+ * Endpoints, so without this trigger routes keep the stale pod port until the resync.
+ */
+public final class HttpRouteServiceHandler extends HttpRouteBackendHandler implements ResourceEventHandler<V1Service> {
+
+    public HttpRouteServiceHandler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+                                   final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        super(httpRouteInformer, httpRouteWorkQueue);
+    }
+
+    @Override
+    public void onAdd(final V1Service service) {
+        enqueueAffectedRoutes(namespaceOf(service.getMetadata()), nameOf(service.getMetadata()));
+    }
+
+    @Override
+    public void onUpdate(final V1Service oldService, final V1Service newService) {
+        enqueueAffectedRoutes(namespaceOf(newService.getMetadata()), nameOf(newService.getMetadata()));
+    }
+
+    @Override
+    public void onDelete(final V1Service service, final boolean unknownState) {
+        enqueueAffectedRoutes(namespaceOf(service.getMetadata()), nameOf(service.getMetadata()));
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/ReferenceGrantReconciler.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/ReferenceGrantReconciler.java
new file mode 100644
index 0000000000..184e025c57
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/reconciler/ReferenceGrantReconciler.java
@@ -0,0 +1,117 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.reconciler;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.common.JsonFields;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Objects;
+
+/**
+ * Re-queues every HTTPRoute referencing the grant's namespace, so a revoked grant stops
+ * unauthorized traffic immediately instead of at the next route resync.
+ */
+public class ReferenceGrantReconciler implements Reconciler {
+
+    private static final Logger LOG = LoggerFactory.getLogger(ReferenceGrantReconciler.class);
+
+    private final Lister<DynamicKubernetesObject> httpRouteLister;
+
+    private final RateLimitingQueue<Request> httpRouteWorkQueue;
+
+    public ReferenceGrantReconciler(final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer,
+                                    final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        this.httpRouteLister = new Lister<>(httpRouteInformer.getIndexer());
+        this.httpRouteWorkQueue = httpRouteWorkQueue;
+    }
+
+    @Override
+    public Result reconcile(final Request request) {
+        String grantNamespace = request.getNamespace();
+        LOG.debug("ReferenceGrant {}/{} changed, re-queuing routes referencing namespace {}",
+                grantNamespace, request.getName(), grantNamespace);
+        try {
+            for (DynamicKubernetesObject route : httpRouteLister.list()) {
+                String routeNamespace = Objects.requireNonNull(route.getMetadata()).getNamespace();
+                // Same-namespace references never consult grants
+                if (grantNamespace.equals(routeNamespace)) {
+                    continue;
+                }
+                if (referencesNamespace(route, grantNamespace)) {
+                    httpRouteWorkQueue.add(new Request(routeNamespace, route.getMetadata().getName()));
+                    LOG.info("Re-queued HTTPRoute {}/{} due to ReferenceGrant change in namespace {}",
+                            routeNamespace, route.getMetadata().getName(), grantNamespace);
+                }
+            }
+            return new Result(false);
+        } catch (Exception e) {
+            LOG.error("Error reconciling ReferenceGrant {}, will retry", request, e);
+            return new Result(true);
+        }
+    }
+
+    /** Cross-namespace parentRef or backendRef into the namespace: exactly what a grant there permits or denies. */
+    private boolean referencesNamespace(final DynamicKubernetesObject route, final String targetNamespace) {
+        JsonObject spec = JsonFields.getJsonObject(route.getRaw(), "spec");
+        if (Objects.isNull(spec)) {
+            return false;
+        }
+        if (referencesNamespace(JsonFields.getJsonArray(spec, "parentRefs"), targetNamespace)) {
+            return true;
+        }
+        JsonArray rules = JsonFields.getJsonArray(spec, "rules");
+        if (Objects.isNull(rules)) {
+            return false;
+        }
+        for (JsonElement ruleElement : rules) {
+            if (!ruleElement.isJsonObject()) {
+                continue;
+            }
+            if (referencesNamespace(JsonFields.getJsonArray(ruleElement.getAsJsonObject(), "backendRefs"), targetNamespace)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    private boolean referencesNamespace(final JsonArray refs, final String targetNamespace) {
+        if (Objects.isNull(refs)) {
+            return false;
+        }
+        for (JsonElement element : refs) {
+            if (!element.isJsonObject()) {
+                continue;
+            }
+            if (targetNamespace.equals(JsonFields.getString(element.getAsJsonObject(), "namespace"))) {
+                return true;
+            }
+        }
+        return false;
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/repository/ShenyuCacheRepository.java b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/repository/ShenyuCacheRepository.java
index e56d71f3c1..51e5f1f6e5 100644
--- a/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/repository/ShenyuCacheRepository.java
+++ b/shenyu-kubernetes-controller/src/main/java/org/apache/shenyu/k8s/repository/ShenyuCacheRepository.java
@@ -26,6 +26,8 @@ import org.apache.shenyu.common.dto.RuleData;
 import org.apache.shenyu.common.dto.SelectorData;
 import org.apache.shenyu.common.dto.convert.selector.DivideUpstream;
 import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
 import org.apache.shenyu.plugin.base.cache.BaseDataCache;
 import org.apache.shenyu.plugin.base.cache.CommonDiscoveryUpstreamDataSubscriber;
 import org.apache.shenyu.plugin.base.cache.CommonPluginDataSubscriber;
@@ -36,10 +38,13 @@ import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
 import java.sql.Timestamp;
+import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
+import java.util.Objects;
 import java.util.Optional;
 import java.util.Properties;
+import java.util.Set;
 import java.util.stream.Collectors;
 
 /**
@@ -114,22 +119,92 @@ public class ShenyuCacheRepository {
     }
 
     /**
-     * Save or update SelectorData by SelectorData.
+     * Save or update SelectorData by SelectorData. Idempotent: a selector whose cached
+     * content is already equal (including the upstream list in its handle) is skipped, so
+     * the periodic informer resync does not churn the data plane with no-op updates.
      *
      * @param selectorData SelectorData
      */
     public void saveOrUpdateSelectorData(final SelectorData selectorData) {
-        subscriber.onSelectorSubscribe(selectorData);
+        List<DiscoveryUpstreamData> upstreamDataList = new ArrayList<>(convert(selectorData.getPluginName(), selectorData.getHandle()));
+        Set<String> newKeys = upstreamDataList.stream().map(ShenyuCacheRepository::upstreamKey).collect(Collectors.toSet());
+        List<Upstream> cachedUpstreams = UpstreamCacheManager.getInstance().findUpstreamListBySelectorId(selectorData.getId());
+        Set<String> cachedKeys = CollectionUtils.isEmpty(cachedUpstreams) ? Collections.emptySet()
+                : cachedUpstreams.stream().map(ShenyuCacheRepository::upstreamKey).collect(Collectors.toSet());
+        if (CollectionUtils.isNotEmpty(cachedUpstreams)) {
+            for (Upstream cached : cachedUpstreams) {
+                if (!newKeys.contains(upstreamKey(cached))) {
+                    upstreamDataList.add(buildOfflineUpstreamData(cached.getUrl(), cached.getProtocol()));
+                }
+            }
+        }
+        boolean upstreamsChanged = !cachedKeys.equals(newKeys);
+        SelectorData cachedSelector = findSelectorData(selectorData.getPluginName(), selectorData.getId());
+        boolean selectorChanged = !selectorData.equals(cachedSelector);
+        if (!selectorChanged && !upstreamsChanged) {
+            return;
+        }
         DiscoverySyncData discoverySyncData = new DiscoverySyncData();
         discoverySyncData.setSelectorName(selectorData.getName());
         discoverySyncData.setSelectorId(selectorData.getId());
         discoverySyncData.setPluginName(selectorData.getPluginName());
-        discoverySyncData.setUpstreamDataList(convert(selectorData.getPluginName(), selectorData.getHandle()));
+        discoverySyncData.setUpstreamDataList(upstreamDataList);
         saveOrUpdateDiscoveryUpstreamData(discoverySyncData);
+        if (upstreamsChanged) {
+            LOG.info("Resolved {} upstream(s) for selector {}", newKeys.size(), selectorData.getId());
+        }
+        if (selectorChanged) {
+            subscriber.onSelectorSubscribe(selectorData);
+            LOG.info("Published divide selector {} for HTTPRoute coordinates {}", selectorData.getId(), selectorData.getName());
+        }
+    }
+
+    /**
+     * Upstream identity key, matching {@link UpstreamCacheManager}'s merge key: protocol and
+     * URL together. Comparing URL alone would leave a stale entry routable when the protocol
+     * of an address changes (http:// → https://), since neither submit nor the offline loop
+     * would evict the old-protocol entry.
+     */
+    private static String upstreamKey(final String protocol, final String url) {
+        return Objects.requireNonNullElse(protocol, "") + "_" + Objects.requireNonNullElse(url, "");
+    }
+
+    private static String upstreamKey(final DiscoveryUpstreamData upstreamData) {
+        return upstreamKey(upstreamData.getProtocol(), upstreamData.getUrl());
+    }
+
+    private static String upstreamKey(final Upstream upstream) {
+        return upstreamKey(upstream.getProtocol(), upstream.getUrl());
+    }
+
+    /**
+     * Find a cached SelectorData by plugin name and selector id.
+     *
+     * @param pluginName plugin name
+     * @param selectorId selector id
+     * @return the cached selector, or null
+     */
+    public SelectorData findSelectorData(final String pluginName, final String selectorId) {
+        List<SelectorData> selectors = BaseDataCache.getInstance().obtainSelectorData(pluginName);
+        if (CollectionUtils.isEmpty(selectors)) {
+            return null;
+        }
+        return selectors.stream()
+                .filter(selector -> selector.getId().equals(selectorId))
+                .findFirst().orElse(null);
+    }
+
+    private DiscoveryUpstreamData buildOfflineUpstreamData(final String url, final String protocol) {
+        DiscoveryUpstreamData upstreamData = new DiscoveryUpstreamData();
+        upstreamData.setUrl(url);
+        upstreamData.setProtocol(protocol);
+        // status 1 marks the entry offline, which is how submit evicts it from the cache
+        upstreamData.setStatus(1);
+        upstreamData.setDateUpdated(new Timestamp(System.currentTimeMillis()));
+        return upstreamData;
     }
 
     private List<DiscoveryUpstreamData> convert(final String pluginName, final String handle) {
-        LOG.info("saveOrUpdateSelectorData convert handle={}", handle);
         List<DivideUpstream> divideUpstreams = GsonUtils.getInstance().fromList(handle, DivideUpstream.class);
         if (CollectionUtils.isEmpty(divideUpstreams)) {
             return Collections.emptyList();
@@ -169,22 +244,46 @@ public class ShenyuCacheRepository {
         subscriber.unSelectorSubscribe(SelectorData.builder().pluginName(pluginName).id(selectorId).build());
     }
 
+    /**
+     * Delete a selector together with all rules still attached to it. findRuleDataList
+     * exposes the cache's mutable internal list, so iterate over a copy to avoid
+     * ConcurrentModificationException.
+     *
+     * @param pluginName plugin name
+     * @param selectorId selector id
+     */
+    public void deleteSelectorWithRules(final String pluginName, final String selectorId) {
+        List<RuleData> rules = findRuleDataList(selectorId);
+        if (CollectionUtils.isNotEmpty(rules)) {
+            for (RuleData rule : new ArrayList<>(rules)) {
+                deleteRuleData(pluginName, selectorId, rule.getId());
+            }
+        }
+        deleteSelectorData(pluginName, selectorId);
+    }
+
     /**
      * Find RuleData list by selector id.
      *
      * @param selectorId selector id
-     * @return RuleData list
+     * @return RuleData list, never null; empty when the selector has no cached rules yet
      */
     public List<RuleData> findRuleDataList(final String selectorId) {
-        return BaseDataCache.getInstance().obtainRuleData(selectorId);
+        return Optional.ofNullable(BaseDataCache.getInstance().obtainRuleData(selectorId)).orElse(Collections.emptyList());
     }
 
     /**
-     * Save or update RuleData by RuleData.
+     * Save or update RuleData by RuleData. Idempotent like the selector path: an unchanged
+     * rule is skipped so the periodic resync does not churn the data plane.
      *
      * @param ruleData RuleData
      */
     public void saveOrUpdateRuleData(final RuleData ruleData) {
+        boolean unchanged = findRuleDataList(ruleData.getSelectorId()).stream()
+                .anyMatch(cached -> cached.getId().equals(ruleData.getId()) && cached.equals(ruleData));
+        if (unchanged) {
+            return;
+        }
         subscriber.onRuleSubscribe(ruleData);
     }
 
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayClassReconcilerTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayClassReconcilerTest.java
new file mode 100644
index 0000000000..daf332236a
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayClassReconcilerTest.java
@@ -0,0 +1,405 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.common.GatewayApiCrdVersions;
+import org.apache.shenyu.k8s.reconciler.GatewayClassReconciler;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.Set;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * GatewayClass Reconciler Test: acceptance (with SupportedVersion and supportedFeatures
+ * reporting), parametersRef rejection, the spec-defined finalizer lifecycle, foreign
+ * classes, and the ownership-loss transition (controllerName re-pointed away from ShenYu)
+ * which must re-queue previously served Gateways immediately instead of waiting for their
+ * resync.
+ */
+public final class GatewayClassReconcilerTest {
+
+    private static final String SHENYU_CONTROLLER = "gateway.shenyu.apache.org/shenyu-controller";
+
+    private static final String FINALIZER = "gateway-exists-finalizer.gateway.networking.k8s.io";
+
+    private static final String STATUS_PATH_SUFFIX = "/status";
+
+    private RateLimitingQueue<Request> gatewayWorkQueue;
+
+    private ApiClient reconcilerApi;
+
+    private ArgumentCaptor<String> pathCaptor;
+
+    private ArgumentCaptor<Object> bodyCaptor;
+
+    @BeforeEach
+    public void setUp() {
+        GatewayRouteCache.getInstance().clear();
+    }
+
+    /** A ShenYu GatewayClass without Accepted status gets Accepted=True patched together with the SupportedVersion condition and the supportedFeatures list. */
+    @Test
+    public void testShenYuGatewayClassGetsAcceptedStatus() throws Exception {
+        GatewayClassReconciler reconciler = reconciler(
+                gatewayClass("shenyu", SHENYU_CONTROLLER, null), null);
+
+        Result result = reconciler.reconcile(new Request("", "shenyu"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(reconcilerApi).execute(any(okhttp3.Call.class));
+
+        JsonObject status = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status");
+        Assertions.assertEquals(2, status.getAsJsonArray("conditions").size());
+        JsonObject accepted = condition(status, "Accepted");
+        Assertions.assertEquals("True", accepted.get("status").getAsString());
+        JsonObject supportedVersion = condition(status, "SupportedVersion");
+        Assertions.assertEquals("True", supportedVersion.get("status").getAsString());
+        Assertions.assertEquals("SupportedVersion", supportedVersion.get("reason").getAsString());
+        Assertions.assertEquals(3, status.getAsJsonArray("supportedFeatures").size());
+        Assertions.assertEquals("Gateway", featureName(status, 0));
+        Assertions.assertEquals("HTTPRoute", featureName(status, 1));
+        Assertions.assertEquals("ReferenceGrant", featureName(status, 2));
+    }
+
+    /** Unsupported CRD bundle versions still accept the class (best effort) but flip the SupportedVersion condition to False with the standard reason. */
+    @Test
+    public void testUnsupportedCrdVersionsFlipSupportedVersion() throws Exception {
+        GatewayClassReconciler reconciler = reconciler(
+                gatewayClass("shenyu", SHENYU_CONTROLLER, null), null,
+                new GatewayApiCrdVersions(List.of("main"), false));
+
+        Result result = reconciler.reconcile(new Request("", "shenyu"));
+        Assertions.assertEquals(new Result(false), result);
+
+        JsonObject status = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status");
+        Assertions.assertEquals("True", condition(status, "Accepted").get("status").getAsString());
+        Assertions.assertEquals("False", condition(status, "SupportedVersion").get("status").getAsString());
+        Assertions.assertEquals("UnsupportedVersion", condition(status, "SupportedVersion").get("reason").getAsString());
+    }
+
+    /** Steady state: an already-reported status (Accepted=True at the current generation, matching SupportedVersion and supportedFeatures) produces no patch at all. */
+    @Test
+    public void testSteadyStateStatusIsNotPatchedAgain() throws Exception {
+        GatewayClassReconciler reconciler = reconciler(
+                gatewayClass("shenyu", SHENYU_CONTROLLER, steadyStatus()), null);
+
+        Result result = reconciler.reconcile(new Request("", "shenyu"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(reconcilerApi, never()).execute(any(okhttp3.Call.class));
+    }
+
+    /** A class with a parametersRef is rejected with Accepted=False/InvalidParameters and its never-served Gateways are left alone. */
+    @Test
+    public void testParametersRefClassIsRejected() throws Exception {
+        final DynamicKubernetesObject gatewayClass = gatewayClass("shenyu", SHENYU_CONTROLLER, null);
+        JsonObject parametersRef = new JsonObject();
+        parametersRef.addProperty("group", "gateway.shenyu.apache.org");
+        parametersRef.addProperty("kind", "ShenyuClassConfig");
+        parametersRef.addProperty("name", "cfg");
+        gatewayClass.getRaw().getAsJsonObject("spec").add("parametersRef", parametersRef);
+
+        GatewayClassReconciler reconciler = reconciler(gatewayClass, null);
+
+        Result result = reconciler.reconcile(new Request("", "shenyu"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(gatewayWorkQueue, never()).add(any(Request.class));
+        verify(reconcilerApi).execute(any(okhttp3.Call.class));
+
+        JsonObject status = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status");
+        JsonObject rejected = condition(status, "Accepted");
+        Assertions.assertEquals("False", rejected.get("status").getAsString());
+        Assertions.assertEquals("InvalidParameters", rejected.get("reason").getAsString());
+    }
+
+    /** Ownership loss: the class was accepted by ShenYu (our Accepted=True payload) and its controllerName moved to another controller. */
+    @Test
+    public void testOwnershipLossRequeuesServedGatewaysAndDowngradesStatus() throws Exception {
+        JsonObject accepted = new JsonObject();
+        accepted.addProperty("type", "Accepted");
+        accepted.addProperty("status", "True");
+        accepted.addProperty("reason", "Accepted");
+        accepted.addProperty("message", "GatewayClass has been accepted by the ShenYu controller");
+        JsonArray conditions = new JsonArray();
+        conditions.add(accepted);
+        JsonObject status = new JsonObject();
+        status.add("conditions", conditions);
+
+        GatewayRouteCache cache = GatewayRouteCache.getInstance();
+        cache.bindRouteToGateway("mockedNamespace", "shenyu-gateway", Set.of("http"),
+                "mockedNamespace", "test-route");
+
+        GatewayClassReconciler reconciler = reconciler(
+                gatewayClass("shenyu", "example.com/other-controller", status),
+                gateway("mockedNamespace", "shenyu-gateway", "shenyu"));
+
+        Result result = reconciler.reconcile(new Request("", "shenyu"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(gatewayWorkQueue).add(new Request("mockedNamespace", "shenyu-gateway"));
+        verify(reconcilerApi).execute(any(okhttp3.Call.class));
+
+        JsonObject patched = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status");
+        Assertions.assertEquals("False", condition(patched, "Accepted").get("status").getAsString());
+        Assertions.assertEquals("Unsupported", condition(patched, "Accepted").get("reason").getAsString());
+    }
+
+    /** A foreign class ShenYu never served (no bindings, no ShenYu-written status) must be skipped entirely: re-queuing its Gateways or patching its status would fight the controller that owns it. */
+    @Test
+    public void testForeignGatewayClassNeverServedIsSkipped() throws Exception {
+        GatewayClassReconciler reconciler = reconciler(
+                gatewayClass("other-class", "example.com/other-controller", null),
+                gateway("mockedNamespace", "some-gateway", "other-class"));
+
+        Result result = reconciler.reconcile(new Request("", "other-class"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(gatewayWorkQueue, never()).add(any(Request.class));
+        verify(reconcilerApi, never()).execute(any(okhttp3.Call.class));
+    }
+
+    /** An accepted class used by a Gateway gains the spec-defined finalizer through a patch on the main resource (not the /status subresource). */
+    @Test
+    public void testFinalizerAddedWhenGatewayUsesClass() throws Exception {
+        GatewayClassReconciler reconciler = reconciler(
+                gatewayClass("shenyu", SHENYU_CONTROLLER, null),
+                gateway("mockedNamespace", "shenyu-gateway", "shenyu"));
+
+        Result result = reconciler.reconcile(new Request("", "shenyu"));
+        Assertions.assertEquals(new Result(false), result);
+
+        boolean finalizerPatchFound = false;
+        for (int i = 0; i < pathCaptor.getAllValues().size(); i++) {
+            String path = pathCaptor.getAllValues().get(i);
+            if (!path.endsWith(STATUS_PATH_SUFFIX)) {
+                finalizerPatchFound = true;
+                JsonArray finalizers = ((JsonObject) bodyCaptor.getAllValues().get(i))
+                        .getAsJsonObject("metadata").getAsJsonArray("finalizers");
+                Assertions.assertEquals(1, finalizers.size());
+                Assertions.assertEquals(FINALIZER, finalizers.get(0).getAsString());
+            }
+        }
+        Assertions.assertTrue(finalizerPatchFound, "a finalizer patch on the main resource is expected");
+    }
+
+    /** The finalizer is removed once no Gateway references the class anymore. */
+    @Test
+    public void testFinalizerRemovedWhenNoGatewayUsesClass() throws Exception {
+        DynamicKubernetesObject gatewayClass = gatewayClass("shenyu", SHENYU_CONTROLLER, null);
+        JsonArray finalizers = new JsonArray();
+        finalizers.add(FINALIZER);
+        gatewayClass.getRaw().getAsJsonObject("metadata").add("finalizers", finalizers);
+
+        GatewayClassReconciler reconciler = reconciler(gatewayClass, null);
+
+        Result result = reconciler.reconcile(new Request("", "shenyu"));
+        Assertions.assertEquals(new Result(false), result);
+
+        boolean removalPatchFound = false;
+        for (int i = 0; i < pathCaptor.getAllValues().size(); i++) {
+            if (!pathCaptor.getAllValues().get(i).endsWith(STATUS_PATH_SUFFIX)) {
+                removalPatchFound = true;
+                JsonArray patched = ((JsonObject) bodyCaptor.getAllValues().get(i))
+                        .getAsJsonObject("metadata").getAsJsonArray("finalizers");
+                Assertions.assertEquals(0, patched.size());
+            }
+        }
+        Assertions.assertTrue(removalPatchFound, "a finalizer removal patch is expected");
+    }
+
+    /** A deleting class gets no status writes, keeps the finalizer while Gateways still reference it, and is released once they are gone. */
+    @Test
+    public void testDeletingClassKeepsFinalizerWhileInUseAndReleasesAfterDrain() throws Exception {
+        DynamicKubernetesObject inUse = deletingClass();
+        GatewayClassReconciler reconciler = reconciler(inUse,
+                gateway("mockedNamespace", "shenyu-gateway", "shenyu"));
+        Assertions.assertEquals(new Result(false), reconciler.reconcile(new Request("", "shenyu")));
+        // no status patch, no finalizer change: the class is still in use
+        verify(reconcilerApi, never()).execute(any(okhttp3.Call.class));
+        verify(gatewayWorkQueue).add(new Request("mockedNamespace", "shenyu-gateway"));
+
+        DynamicKubernetesObject drained = deletingClass();
+        reconciler = reconciler(drained, null);
+        Assertions.assertEquals(new Result(false), reconciler.reconcile(new Request("", "shenyu")));
+        verify(reconcilerApi, times(1)).execute(any(okhttp3.Call.class));
+        Assertions.assertFalse(pathCaptor.getValue().endsWith(STATUS_PATH_SUFFIX));
+        JsonArray patched = ((JsonObject) bodyCaptor.getValue())
+                .getAsJsonObject("metadata").getAsJsonArray("finalizers");
+        Assertions.assertEquals(0, patched.size());
+    }
+
+    private DynamicKubernetesObject deletingClass() {
+        DynamicKubernetesObject gatewayClass = gatewayClass("shenyu", SHENYU_CONTROLLER, null);
+        JsonArray finalizers = new JsonArray();
+        finalizers.add(FINALIZER);
+        gatewayClass.getRaw().getAsJsonObject("metadata").add("finalizers", finalizers);
+        gatewayClass.getRaw().getAsJsonObject("metadata").addProperty("deletionTimestamp", "2026-01-01T00:00:00Z");
+        return gatewayClass;
+    }
+
+    private static JsonObject condition(final JsonObject status, final String type) {
+        JsonArray conditions = status.getAsJsonArray("conditions");
+        for (JsonElement element : conditions) {
+            if (type.equals(element.getAsJsonObject().get("type").getAsString())) {
+                return element.getAsJsonObject();
+            }
+        }
+        throw new AssertionError("condition " + type + " not found");
+    }
+
+    private static String featureName(final JsonObject status, final int index) {
+        return status.getAsJsonArray("supportedFeatures").get(index).getAsJsonObject()
+                .get("name").getAsString();
+    }
+
+    /** The full status an accepted, fully reported class carries in steady state. */
+    private static JsonObject steadyStatus() {
+        JsonObject accepted = new JsonObject();
+        accepted.addProperty("type", "Accepted");
+        accepted.addProperty("status", "True");
+        accepted.addProperty("reason", "Accepted");
+        accepted.addProperty("message", "GatewayClass has been accepted by the ShenYu controller");
+        accepted.addProperty("observedGeneration", 1L);
+        accepted.addProperty("lastTransitionTime", "2026-01-01T00:00:00Z");
+        JsonObject supportedVersion = new JsonObject();
+        supportedVersion.addProperty("type", "SupportedVersion");
+        supportedVersion.addProperty("status", "True");
+        supportedVersion.addProperty("reason", "SupportedVersion");
+        supportedVersion.addProperty("message", "detected Gateway API CRD bundle version(s): v1.5.1; supported: >= v1.5.0");
+        supportedVersion.addProperty("observedGeneration", 1L);
+        supportedVersion.addProperty("lastTransitionTime", "2026-01-01T00:00:00Z");
+        JsonArray conditions = new JsonArray();
+        conditions.add(accepted);
+        conditions.add(supportedVersion);
+
+        JsonArray supportedFeatures = new JsonArray();
+        for (String name : List.of("Gateway", "HTTPRoute", "ReferenceGrant")) {
+            JsonObject feature = new JsonObject();
+            feature.addProperty("name", name);
+            supportedFeatures.add(feature);
+        }
+
+        JsonObject status = new JsonObject();
+        status.add("conditions", conditions);
+        status.add("supportedFeatures", supportedFeatures);
+        return status;
+    }
+
+    private GatewayClassReconciler reconciler(final DynamicKubernetesObject gatewayClass,
+                                              final DynamicKubernetesObject gateway) {
+        return reconciler(gatewayClass, gateway, new GatewayApiCrdVersions(List.of("v1.5.1"), true));
+    }
+
+    private GatewayClassReconciler reconciler(final DynamicKubernetesObject gatewayClass,
+                                              final DynamicKubernetesObject gateway,
+                                              final GatewayApiCrdVersions crdVersions) {
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayClassIndexer = mock(Indexer.class);
+        when(gatewayClassIndexer.getByKey("shenyu")).thenReturn(gatewayClass);
+        when(gatewayClassIndexer.getByKey("other-class")).thenReturn(gatewayClass);
+        when(gatewayClassInformer.getIndexer()).thenReturn(gatewayClassIndexer);
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        if (Objects.nonNull(gateway)) {
+            when(gatewayIndexer.list()).thenReturn(List.of(gateway));
+        } else {
+            when(gatewayIndexer.list()).thenReturn(List.of());
+        }
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        gatewayWorkQueue = mock(RateLimitingQueue.class);
+        reconcilerApi = mock(ApiClient.class);
+        pathCaptor = ArgumentCaptor.forClass(String.class);
+        bodyCaptor = ArgumentCaptor.forClass(Object.class);
+        try {
+            when(reconcilerApi.getAuthentications()).thenReturn(Map.of());
+            when(reconcilerApi.buildCall(pathCaptor.capture(), any(), any(), any(), bodyCaptor.capture(),
+                    any(), any(), any(), any(), any()))
+                    .thenReturn(mock(okhttp3.Call.class));
+        } catch (ApiException e) {
+            throw new IllegalStateException(e);
+        }
+        return new GatewayClassReconciler(gatewayClassInformer, gatewayInformer, gatewayWorkQueue, reconcilerApi, crdVersions);
+    }
+
+    private DynamicKubernetesObject gatewayClass(final String name, final String controllerName,
+                                                 final JsonObject status) {
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("name", name);
+        metadata.addProperty("generation", 1L);
+
+        JsonObject spec = new JsonObject();
+        spec.addProperty("controllerName", controllerName);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "GatewayClass");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        if (Objects.nonNull(status)) {
+            raw.add("status", status);
+        }
+        return new DynamicKubernetesObject(raw);
+    }
+
+    private DynamicKubernetesObject gateway(final String namespace, final String name,
+                                            final String gatewayClassName) {
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", namespace);
+        metadata.addProperty("name", name);
+
+        JsonObject listener = new JsonObject();
+        listener.addProperty("name", "http");
+        listener.addProperty("protocol", "HTTP");
+        listener.addProperty("port", 9195);
+        JsonArray listeners = new JsonArray();
+        listeners.add(listener);
+
+        JsonObject spec = new JsonObject();
+        spec.addProperty("gatewayClassName", gatewayClassName);
+        spec.add("listeners", listeners);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "Gateway");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayReconcilerTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayReconcilerTest.java
new file mode 100644
index 0000000000..ba1f85c85a
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/GatewayReconcilerTest.java
@@ -0,0 +1,402 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.reconciler.GatewayReconciler;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Set;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Gateway Reconciler Test.
+ */
+public final class GatewayReconcilerTest {
+
+    @BeforeEach
+    public void setUp() {
+        GatewayRouteCache.getInstance().clear();
+    }
+
+    /** Test ShenYu Gateway creation. */
+    @Test
+    public void testReconcileShenYuGatewayCreation() throws Exception {
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "shenyu-gateway", "testService", 8189);
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteIndexer.list()).thenReturn(List.of(httpRoute));
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+        RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+        ApiClient apiClient = mockApiClientWithStatusPatch();
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+                httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+        Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(httpRouteWorkQueue).add(new Request("mockedNamespace", "test-route"));
+        // the accept transition re-queues the class so its finalizer is added immediately
+        verify(gatewayClassWorkQueue).add(new Request("", "shenyu"));
+        verify(apiClient).execute(any(okhttp3.Call.class));
+    }
+
+    /** Test Gateway deletion: should cascade delete ShenYu config for associated routes. */
+    @Test
+    public void testReconcileGatewayDeletion() {
+        // gateway not found in indexer → treated as deletion
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(null);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        GatewayRouteCache cache = GatewayRouteCache.getInstance();
+        cache.bindRouteToGateway("mockedNamespace", "shenyu-gateway", Set.of("http"), "mockedNamespace", "test-route");
+        cache.putRouteSelectors("mockedNamespace", "test-route", "divide", List.of("sel-1"));
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+
+        RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+        RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+        ApiClient apiClient = mock(ApiClient.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+                httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+        Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository).deleteSelectorWithRules("divide", "sel-1");
+        // deletion re-queues every class: the one losing its last Gateway must drop its finalizer
+        verify(gatewayClassWorkQueue).add(new Request("", "shenyu"));
+        verify(gatewayClassWorkQueue).add(new Request("", "other-class"));
+    }
+
+    /**
+     * A Gateway whose class carries Accepted=False (ShenYu rejected it) is out of scope: previously served config is
+     * cleaned up and the Gateway status is downgraded with the standard Invalid reason.
+     */
+    @Test
+    public void testRejectedGatewayClassMakesGatewayOutOfScope() throws Exception {
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        GatewayRouteCache cache = GatewayRouteCache.getInstance();
+        cache.bindRouteToGateway("mockedNamespace", "shenyu-gateway", Set.of("http"), "mockedNamespace", "test-route");
+        cache.putRouteSelectors("mockedNamespace", "test-route", "divide", List.of("sel-1"));
+
+        final ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        final RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+        final RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+        ApiClient apiClient = mockApiClientWithStatusPatch();
+        ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+        when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+                .thenReturn(mock(okhttp3.Call.class));
+
+        final DynamicKubernetesObject rejectedClass = buildGatewayClass("shenyu", "gateway.shenyu.apache.org/shenyu-controller");
+        JsonObject rejected = new JsonObject();
+        rejected.addProperty("type", "Accepted");
+        rejected.addProperty("status", "False");
+        rejected.addProperty("reason", "InvalidParameters");
+        JsonObject status = new JsonObject();
+        status.add("conditions", new JsonArray());
+        status.getAsJsonArray("conditions").add(rejected);
+        rejectedClass.getRaw().add("status", status);
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer(rejectedClass);
+        GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+                httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+        Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository).deleteSelectorWithRules("divide", "sel-1");
+
+        JsonObject body = (JsonObject) bodyCaptor.getValue();
+        JsonObject downgraded = body.getAsJsonObject("status").getAsJsonArray("conditions").get(0).getAsJsonObject();
+        Assertions.assertEquals("False", downgraded.get("status").getAsString());
+        Assertions.assertEquals("Invalid", downgraded.get("reason").getAsString());
+    }
+
+    /**
+     * Test that status update is skipped when the Gateway status already reflects the full desired steady state:
+     * Accepted=True and Programmed=True conditions plus a per-listener status entry with the current attachedRoutes
+     * count.
+     */
+    @Test
+    public void testReconcileGatewayAlreadyAccepted() throws Exception {
+        JsonObject statusObj = new JsonObject();
+        statusObj.add("conditions", buildConditions("True", "True"));
+        JsonObject listenerStatus = new JsonObject();
+        listenerStatus.addProperty("name", "http");
+        listenerStatus.addProperty("attachedRoutes", 0);
+        listenerStatus.add("conditions", buildConditions("True", "True"));
+        JsonArray listeners = new JsonArray();
+        listeners.add(listenerStatus);
+        statusObj.add("listeners", listeners);
+
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        gateway.getRaw().add("status", statusObj);
+
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+        RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+        ApiClient apiClient = mock(ApiClient.class);
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+                httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+        Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(apiClient, never()).execute(any(okhttp3.Call.class));
+    }
+
+    /** attachedRoutes is defined per listener: a route bound through only one listener of a two-listener Gateway must be counted on that listener alone, not on both. */
+    @Test
+    public void testAttachedRoutesAreCountedPerListener() throws Exception {
+        final DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        JsonObject secondListener = new JsonObject();
+        secondListener.addProperty("name", "http2");
+        secondListener.addProperty("protocol", "HTTP");
+        secondListener.addProperty("port", 9195);
+        gateway.getRaw().getAsJsonObject("spec").getAsJsonArray("listeners").add(secondListener);
+
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        GatewayRouteCache.getInstance().bindRouteToGateway("mockedNamespace", "shenyu-gateway",
+                Set.of("http"), "mockedNamespace", "test-route");
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        RateLimitingQueue<Request> httpRouteWorkQueue = mock(RateLimitingQueue.class);
+        RateLimitingQueue<Request> gatewayClassWorkQueue = mock(RateLimitingQueue.class);
+        ApiClient apiClient = mock(ApiClient.class);
+        when(apiClient.getAuthentications()).thenReturn(Map.of());
+        ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+        when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+                .thenReturn(mock(okhttp3.Call.class));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        GatewayReconciler gatewayReconciler = new GatewayReconciler(gatewayInformer, gatewayClassInformer,
+                httpRouteInformer, shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, 9195);
+
+        Result result = gatewayReconciler.reconcile(new Request("mockedNamespace", "shenyu-gateway"));
+        Assertions.assertEquals(new Result(false), result);
+
+        JsonArray listeners = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status").getAsJsonArray("listeners");
+        Assertions.assertEquals(2, listeners.size());
+        for (JsonElement element : listeners) {
+            JsonObject listenerStatus = element.getAsJsonObject();
+            String name = listenerStatus.get("name").getAsString();
+            int attached = listenerStatus.get("attachedRoutes").getAsInt();
+            if ("http".equals(name)) {
+                Assertions.assertEquals(1, attached, "listener the route attached to must count it");
+            } else {
+                Assertions.assertEquals(0, attached, "unrelated listener must not count the route");
+            }
+        }
+    }
+
+    private JsonArray buildConditions(final String acceptedStatus, final String programmedStatus) {
+        JsonObject accepted = new JsonObject();
+        accepted.addProperty("type", "Accepted");
+        accepted.addProperty("status", acceptedStatus);
+        JsonObject programmed = new JsonObject();
+        programmed.addProperty("type", "Programmed");
+        programmed.addProperty("status", programmedStatus);
+        JsonArray conditions = new JsonArray();
+        conditions.add(accepted);
+        conditions.add(programmed);
+        return conditions;
+    }
+
+    /**
+     * Mock ApiClient whose status-patch pipeline succeeds: authentications resolve empty
+     * and buildCall returns a mock call accepted by execute.
+     */
+    private ApiClient mockApiClientWithStatusPatch() throws ApiException {
+        ApiClient apiClient = mock(ApiClient.class);
+        when(apiClient.getAuthentications()).thenReturn(Map.of());
+        when(apiClient.buildCall(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn(mock(okhttp3.Call.class));
+        return apiClient;
+    }
+
+    private DynamicKubernetesObject buildGateway(final String namespace, final String name,
+                                                  final String gatewayClassName) {
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", namespace);
+        metadata.addProperty("name", name);
+
+        // a single HTTP listener on the served port; allowedRoutes defaults to Same-namespace
+        JsonObject listener = new JsonObject();
+        listener.addProperty("name", "http");
+        listener.addProperty("protocol", "HTTP");
+        listener.addProperty("port", 9195);
+        JsonArray listeners = new JsonArray();
+        listeners.add(listener);
+
+        JsonObject spec = new JsonObject();
+        spec.addProperty("gatewayClassName", gatewayClassName);
+        spec.add("listeners", listeners);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "Gateway");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+
+    private DynamicKubernetesObject buildGatewayClass(final String name, final String controllerName) {
+        // GatewayClass is cluster-scoped, no namespace
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("name", name);
+
+        JsonObject spec = new JsonObject();
+        spec.addProperty("controllerName", controllerName);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "GatewayClass");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+
+    /**
+     * Build a mocked gatewayClass informer backed by an Indexer. Lister.get(name) for a
+     * cluster-scoped resource resolves to Indexer.getByKey(name), so gateway classes are
+     * stubbed via getByKey. "shenyu" is ShenYu-owned; "other-class" is non-ShenYu.
+     */
+    private SharedIndexInformer<DynamicKubernetesObject> mockGatewayClassInformer() {
+        return mockGatewayClassInformer(buildGatewayClass("shenyu", "gateway.shenyu.apache.org/shenyu-controller"));
+    }
+
+    /**
+     * Same as {@link #mockGatewayClassInformer()} but with a caller-provided "shenyu"
+     * class, e.g. one already carrying a rejection status.
+     */
+    private SharedIndexInformer<DynamicKubernetesObject> mockGatewayClassInformer(
+            final DynamicKubernetesObject shenyuClass) {
+        SharedIndexInformer<DynamicKubernetesObject> informer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+        DynamicKubernetesObject otherClass = buildGatewayClass("other-class", "example.com/other-controller");
+        when(indexer.getByKey("shenyu")).thenReturn(shenyuClass);
+        when(indexer.getByKey("other-class")).thenReturn(otherClass);
+        // requeueAllGatewayClasses iterates every class after a Gateway deletion
+        when(indexer.list()).thenReturn(List.of(shenyuClass, otherClass));
+        when(informer.getIndexer()).thenReturn(indexer);
+        return informer;
+    }
+
+    private DynamicKubernetesObject buildHTTPRoute(final String routeNamespace, final String routeName,
+                                                    final String gatewayNamespace, final String gatewayName,
+                                                    final String serviceName, final int port) {
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", routeNamespace);
+        metadata.addProperty("name", routeName);
+
+        JsonObject parentRef = new JsonObject();
+        parentRef.addProperty("name", gatewayName);
+        parentRef.addProperty("namespace", gatewayNamespace);
+        JsonArray parentRefs = new JsonArray();
+        parentRefs.add(parentRef);
+
+        JsonObject backendRef = new JsonObject();
+        backendRef.addProperty("name", serviceName);
+        backendRef.addProperty("port", port);
+        JsonArray backendRefs = new JsonArray();
+        backendRefs.add(backendRef);
+
+        JsonObject rule = new JsonObject();
+        rule.add("backendRefs", backendRefs);
+        JsonArray rules = new JsonArray();
+        rules.add(rule);
+
+        JsonObject spec = new JsonObject();
+        spec.add("parentRefs", parentRefs);
+        spec.add("rules", rules);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "HTTPRoute");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HTTPRouteReconcilerTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HTTPRouteReconcilerTest.java
new file mode 100644
index 0000000000..4fc5d89f28
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HTTPRouteReconcilerTest.java
@@ -0,0 +1,676 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Result;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiException;
+import io.kubernetes.client.openapi.models.V1EndpointAddress;
+import io.kubernetes.client.openapi.models.V1EndpointSubsetBuilder;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1EndpointsBuilder;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.cache.GatewayRouteCache;
+import org.apache.shenyu.k8s.parser.HttpRouteParser;
+import org.apache.shenyu.k8s.reconciler.HTTPRouteReconciler;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.List;
+import java.util.Set;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/** HTTPRoute Reconciler Test. */
+public final class HTTPRouteReconcilerTest {
+
+    @BeforeEach
+    public void setUp() {
+        GatewayRouteCache.getInstance().clear();
+    }
+
+    /** Test HTTPRoute bound to a ShenYu Gateway: should create selector and rule. */
+    @Test
+    public void testReconcileBoundHTTPRoute() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+                .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+                .build();
+        when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        ApiClient apiClient = mockApiClientWithStatusPatch();
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository).saveOrUpdateSelectorData(any());
+        verify(shenyuCacheRepository).saveOrUpdateRuleData(any());
+        verify(apiClient).execute(any(okhttp3.Call.class));
+    }
+
+    /** Regression: when the route status already carries Accepted=True and ResolvedRefs=True (capitalized, per K8s condition convention), the reconciler must skip the status patch. */
+    @Test
+    public void testReconcileSkipsStatusPatchWhenAlreadySet() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints mockedEndpoints = new V1EndpointsBuilder()
+                .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("192.168.0.1")).build())
+                .build();
+        when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+        JsonObject accepted = new JsonObject();
+        accepted.addProperty("type", "Accepted");
+        accepted.addProperty("status", "True");
+        JsonObject resolvedRefs = new JsonObject();
+        resolvedRefs.addProperty("type", "ResolvedRefs");
+        resolvedRefs.addProperty("status", "True");
+        JsonArray conditions = new JsonArray();
+        conditions.add(accepted);
+        conditions.add(resolvedRefs);
+        JsonObject parentRef = new JsonObject();
+        parentRef.addProperty("group", "gateway.networking.k8s.io");
+        parentRef.addProperty("kind", "Gateway");
+        parentRef.addProperty("namespace", "mockedNamespace");
+        parentRef.addProperty("name", "shenyu-gateway");
+        JsonObject parent = new JsonObject();
+        parent.add("parentRef", parentRef);
+        parent.addProperty("controllerName", "gateway.shenyu.apache.org/shenyu-controller");
+        parent.add("conditions", conditions);
+        JsonArray parents = new JsonArray();
+        parents.add(parent);
+        JsonObject status = new JsonObject();
+        status.add("parents", parents);
+        httpRoute.getRaw().add("status", status);
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        ApiClient apiClient = mock(ApiClient.class);
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository).saveOrUpdateSelectorData(any());
+        verify(apiClient, never()).execute(any(okhttp3.Call.class));
+    }
+
+    /**
+     * A parentRef carrying a port must only attach through listeners on that port: with the
+     * sectionName selecting a listener whose port differs from the parentRef port, the
+     * attachment is rejected instead of silently going through the named listener.
+     */
+    @Test
+    public void testParentRefPortMustMatchSelectedListener() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+                .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+                .build();
+        when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister,
+                new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+        // parentRef selects the listener by name but demands port 443; the listener serves 9195
+        httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("parentRefs")
+                .get(0).getAsJsonObject().addProperty("port", 443);
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        ApiClient apiClient = mockApiClientWithStatusPatch();
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository, never()).saveOrUpdateSelectorData(any());
+    }
+
+    /** Test HTTPRoute deletion: should clean up selector and rule data. */
+    @Test
+    public void testReconcileHTTPRouteDeletion() {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        // httpRoute not found in indexer → treated as deletion
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(null);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        ApiClient apiClient = mock(ApiClient.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+        Assertions.assertEquals(new Result(false), result);
+        // No exception should be thrown; deleteConfig handles empty cache gracefully
+    }
+
+    /** Idempotent reconcile: re-reconciling an unchanged HTTPRoute must NOT delete any selector, because the deterministic IDs are stable across parses. */
+    @Test
+    public void testReconcileIsIdempotentOnResync() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+                .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+                .build();
+        when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        ApiClient apiClient = mockApiClientWithStatusPatch();
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+        httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+
+        // The reconciler deletes through deleteSelectorWithRules only, so that is the
+        // call an unwanted delete-then-recreate would go through
+        verify(shenyuCacheRepository, never()).deleteSelectorWithRules(any(), any());
+    }
+
+    /**
+     * Cross-namespace parentRef authorized by the listener's allowedRoutes alone: per the
+     * Gateway API spec a ReferenceGrant does not apply to a Route's parentRef, so the
+     * attachment must be accepted without any grant existing in the Gateway's namespace.
+     */
+    @Test
+    public void testReconcileCrossNamespaceHTTPRouteAcceptedByAllowedRoutes() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+                .withNewMetadata().withNamespace("route-ns").withName("testService").endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+                .build();
+        when(endpointsIndexer.getByKey("route-ns/testService")).thenReturn(mockedEndpoints);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = allowAllNamespaces(buildGateway("gw-ns", "shenyu-gateway", "shenyu"));
+        when(gatewayIndexer.getByKey("gw-ns/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("route-ns", "test-route",
+                "gw-ns", "shenyu-gateway", "testService", 8189, "/**");
+        when(httpRouteIndexer.getByKey("route-ns/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        ApiClient apiClient = mockApiClientWithStatusPatch();
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        Result result = httpRouteReconciler.reconcile(new Request("route-ns", "test-route"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository).saveOrUpdateSelectorData(any());
+    }
+
+    /**
+     * A listener with allowedRoutes from=Selector is an unsupported value, not a permission denial:
+     * the route must be rejected with Accepted=False/UnsupportedValue (never RefNotPermitted, which
+     * would suggest a ReferenceGrant could fix it) and nothing may be programmed.
+     */
+    @Test
+    public void testSelectorAllowedRoutesReportsUnsupportedValue() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+        final SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        JsonObject namespaces = new JsonObject();
+        namespaces.addProperty("from", "Selector");
+        JsonObject allowedRoutes = new JsonObject();
+        allowedRoutes.add("namespaces", namespaces);
+        gateway.getRaw().getAsJsonObject("spec").getAsJsonArray("listeners")
+                .get(0).getAsJsonObject().add("allowedRoutes", allowedRoutes);
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+        ApiClient apiClient = mock(ApiClient.class);
+        when(apiClient.getAuthentications()).thenReturn(Map.of());
+        ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+        when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+                .thenReturn(mock(okhttp3.Call.class));
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+        Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository, never()).saveOrUpdateSelectorData(any());
+        JsonArray parents = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status").getAsJsonArray("parents");
+        JsonArray conditions = parents.get(0).getAsJsonObject().getAsJsonArray("conditions");
+        boolean rejectedWithUnsupportedValue = false;
+        for (JsonElement element : conditions) {
+            JsonObject condition = element.getAsJsonObject();
+            if ("Accepted".equals(condition.get("type").getAsString())
+                    && "False".equals(condition.get("status").getAsString())
+                    && "UnsupportedValue".equals(condition.get("reason").getAsString())) {
+                rejectedWithUnsupportedValue = true;
+            }
+        }
+        Assertions.assertTrue(rejectedWithUnsupportedValue, "Accepted=False/UnsupportedValue not reported");
+    }
+
+    /**
+     * A rule with filters (unsupported) must reject the whole route: nothing is programmed,
+     * config from a previous valid spec of this route is dropped, and the status patch
+     * reports Accepted=False with the spec-defined reason UnsupportedValue.
+     */
+    @Test
+    public void testReconcileUnsupportedFiltersProgramsNothing() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+                .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+                .build();
+        when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+        JsonObject filter = new JsonObject();
+        filter.addProperty("type", "RequestHeaderModifier");
+        JsonArray filters = new JsonArray();
+        filters.add(filter);
+        httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules")
+                .get(0).getAsJsonObject().add("filters", filters);
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        // config programmed by a previous, valid spec of this route
+        GatewayRouteCache cache = GatewayRouteCache.getInstance();
+        cache.putRouteSelectors("mockedNamespace", "test-route", "divide", List.of("sel-1"));
+
+        ApiClient apiClient = mock(ApiClient.class);
+        when(apiClient.getAuthentications()).thenReturn(Map.of());
+        ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+        when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+                .thenReturn(mock(okhttp3.Call.class));
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository, never()).saveOrUpdateSelectorData(any());
+        verify(shenyuCacheRepository).deleteSelectorWithRules("divide", "sel-1");
+
+        JsonArray parents = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status").getAsJsonArray("parents");
+        JsonArray conditions = parents.get(0).getAsJsonObject().getAsJsonArray("conditions");
+        boolean rejectedWithUnsupportedValue = false;
+        for (JsonElement element : conditions) {
+            JsonObject condition = element.getAsJsonObject();
+            if ("Accepted".equals(condition.get("type").getAsString())
+                    && "False".equals(condition.get("status").getAsString())
+                    && "UnsupportedValue".equals(condition.get("reason").getAsString())) {
+                rejectedWithUnsupportedValue = true;
+            }
+        }
+        Assertions.assertTrue(rejectedWithUnsupportedValue, "Accepted=False/UnsupportedValue not reported");
+    }
+
+    /** Status entries owned by other controllers must survive ShenYu's status patch: the patch body carries them over, because the merge patch replaces the parents array wholesale. */
+    @Test
+    public void testStatusPatchPreservesForeignControllerEntries() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+                .withNewMetadata().withNamespace("mockedNamespace").withName("testService").endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("127.0.0.1")).build())
+                .build();
+        when(endpointsIndexer.getByKey("mockedNamespace/testService")).thenReturn(mockedEndpoints);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject gateway = buildGateway("mockedNamespace", "shenyu-gateway", "shenyu");
+        when(gatewayIndexer.getByKey("mockedNamespace/shenyu-gateway")).thenReturn(gateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "shenyu-gateway", "testService", 8189, "/**");
+        JsonObject foreignRef = new JsonObject();
+        foreignRef.addProperty("group", "gateway.networking.k8s.io");
+        foreignRef.addProperty("kind", "Gateway");
+        foreignRef.addProperty("namespace", "mockedNamespace");
+        foreignRef.addProperty("name", "other-gateway");
+        JsonObject foreignParent = new JsonObject();
+        foreignParent.add("parentRef", foreignRef);
+        foreignParent.addProperty("controllerName", "example.net/other-gateway-controller");
+        foreignParent.add("conditions", new JsonArray());
+        JsonArray existingParents = new JsonArray();
+        existingParents.add(foreignParent);
+        JsonObject status = new JsonObject();
+        status.add("parents", existingParents);
+        httpRoute.getRaw().add("status", status);
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        ApiClient apiClient = mock(ApiClient.class);
+        when(apiClient.getAuthentications()).thenReturn(Map.of());
+        ArgumentCaptor<Object> bodyCaptor = ArgumentCaptor.forClass(Object.class);
+        when(apiClient.buildCall(any(), any(), any(), any(), bodyCaptor.capture(), any(), any(), any(), any(), any()))
+                .thenReturn(mock(okhttp3.Call.class));
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+
+        verify(apiClient).execute(any(okhttp3.Call.class));
+        JsonArray patchedParents = ((JsonObject) bodyCaptor.getValue()).getAsJsonObject("status").getAsJsonArray("parents");
+        Assertions.assertEquals(2, patchedParents.size(), "patch must carry the foreign entry plus ShenYu's own");
+        boolean foreignKept = false;
+        boolean shenyuPresent = false;
+        for (JsonElement element : patchedParents) {
+            String controllerName = element.getAsJsonObject().get("controllerName").getAsString();
+            if ("example.net/other-gateway-controller".equals(controllerName)) {
+                foreignKept = true;
+            }
+            if ("gateway.shenyu.apache.org/shenyu-controller".equals(controllerName)) {
+                shenyuPresent = true;
+            }
+        }
+        Assertions.assertTrue(foreignKept, "foreign controller entry was dropped by the patch");
+        Assertions.assertTrue(shenyuPresent, "ShenYu's own entry missing from the patch");
+    }
+
+    /** Mock ApiClient whose status-patch pipeline succeeds: authentications resolve empty and buildCall returns a mock call accepted by execute. */
+    private ApiClient mockApiClientWithStatusPatch() throws ApiException {
+        ApiClient apiClient = mock(ApiClient.class);
+        when(apiClient.getAuthentications()).thenReturn(Map.of());
+        when(apiClient.buildCall(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn(mock(okhttp3.Call.class));
+        return apiClient;
+    }
+
+    /**
+     * A previously bound HTTPRoute that is no longer bound to any ShenYu Gateway (grant
+     * removed, GatewayClass re-pointed, listener removed) must have its programmed
+     * selectors deleted and its ShenYu status entries dropped, instead of being skipped
+     * with stale config left behind.
+     */
+    @Test
+    public void testReconcileUnboundHTTPRouteCleansUpPreviouslyAppliedConfig() throws Exception {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+        final HttpRouteParser httpRouteParser = new HttpRouteParser(endpointsLister, new Lister<>(mock(Indexer.class)), new Lister<>(mock(Indexer.class)));
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> gatewayIndexer = mock(Indexer.class);
+        DynamicKubernetesObject otherGateway = buildGateway("mockedNamespace", "other-gateway", "other-class");
+        when(gatewayIndexer.getByKey("mockedNamespace/other-gateway")).thenReturn(otherGateway);
+        when(gatewayInformer.getIndexer()).thenReturn(gatewayIndexer);
+
+        final SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer = mock(SharedIndexInformer.class);
+        final Indexer<DynamicKubernetesObject> httpRouteIndexer = mock(Indexer.class);
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute("mockedNamespace", "test-route",
+                "mockedNamespace", "other-gateway", "testService", 8189, "/**");
+        JsonObject shenyuParent = new JsonObject();
+        shenyuParent.addProperty("controllerName", "gateway.shenyu.apache.org/shenyu-controller");
+        JsonArray parents = new JsonArray();
+        parents.add(shenyuParent);
+        JsonObject status = new JsonObject();
+        status.add("parents", parents);
+        httpRoute.getRaw().add("status", status);
+        when(httpRouteIndexer.getByKey("mockedNamespace/test-route")).thenReturn(httpRoute);
+        when(httpRouteInformer.getIndexer()).thenReturn(httpRouteIndexer);
+
+        GatewayRouteCache cache = GatewayRouteCache.getInstance();
+        cache.bindRouteToGateway("mockedNamespace", "other-gateway", Set.of("http"), "mockedNamespace", "test-route");
+        cache.putRouteSelectors("mockedNamespace", "test-route", "divide", List.of("sel-1"));
+
+        ShenyuCacheRepository shenyuCacheRepository = mock(ShenyuCacheRepository.class);
+        ApiClient apiClient = mockApiClientWithStatusPatch();
+
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer = mockGatewayClassInformer();
+        HTTPRouteReconciler httpRouteReconciler = new HTTPRouteReconciler(httpRouteInformer, gatewayInformer,
+                gatewayClassInformer, httpRouteParser, shenyuCacheRepository, apiClient, 9195);
+
+        Result result = httpRouteReconciler.reconcile(new Request("mockedNamespace", "test-route"));
+        Assertions.assertEquals(new Result(false), result);
+        verify(shenyuCacheRepository).deleteSelectorWithRules("divide", "sel-1");
+        verify(apiClient).execute(any(okhttp3.Call.class));
+    }
+
+    private DynamicKubernetesObject buildGateway(final String namespace, final String name,
+                                                  final String gatewayClassName) {
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", namespace);
+        metadata.addProperty("name", name);
+
+        // one HTTP listener; allowedRoutes defaults to Same-namespace per the spec
+        JsonObject listener = new JsonObject();
+        listener.addProperty("name", "http");
+        listener.addProperty("protocol", "HTTP");
+        listener.addProperty("port", 9195);
+        JsonArray listeners = new JsonArray();
+        listeners.add(listener);
+
+        JsonObject spec = new JsonObject();
+        spec.addProperty("gatewayClassName", gatewayClassName);
+        spec.add("listeners", listeners);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "Gateway");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+
+    /** Loosen the gateway's listener to accept routes from all namespaces. */
+    private DynamicKubernetesObject allowAllNamespaces(final DynamicKubernetesObject gateway) {
+        JsonObject namespaces = new JsonObject();
+        namespaces.addProperty("from", "All");
+        JsonObject allowedRoutes = new JsonObject();
+        allowedRoutes.add("namespaces", namespaces);
+        gateway.getRaw().getAsJsonObject("spec").getAsJsonArray("listeners")
+                .get(0).getAsJsonObject().add("allowedRoutes", allowedRoutes);
+        return gateway;
+    }
+
+    private DynamicKubernetesObject buildGatewayClass(final String name, final String controllerName) {
+        // GatewayClass is cluster-scoped, no namespace
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("name", name);
+
+        JsonObject spec = new JsonObject();
+        spec.addProperty("controllerName", controllerName);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "GatewayClass");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+
+    /** Build a mocked gatewayClass informer backed by an Indexer. */
+    private SharedIndexInformer<DynamicKubernetesObject> mockGatewayClassInformer() {
+        SharedIndexInformer<DynamicKubernetesObject> informer = mock(SharedIndexInformer.class);
+        Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+        DynamicKubernetesObject shenyuClass = buildGatewayClass("shenyu", "gateway.shenyu.apache.org/shenyu-controller");
+        when(indexer.getByKey("shenyu")).thenReturn(shenyuClass);
+        DynamicKubernetesObject otherClass = buildGatewayClass("other-class", "example.com/other-controller");
+        when(indexer.getByKey("other-class")).thenReturn(otherClass);
+        when(informer.getIndexer()).thenReturn(indexer);
+        return informer;
+    }
+
+    private DynamicKubernetesObject buildHTTPRoute(final String routeNamespace, final String routeName,
+                                                    final String gatewayNamespace, final String gatewayName,
+                                                    final String serviceName, final int port,
+                                                    final String pathValue) {
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", routeNamespace);
+        metadata.addProperty("name", routeName);
+
+        JsonObject parentRef = new JsonObject();
+        parentRef.addProperty("name", gatewayName);
+        parentRef.addProperty("namespace", gatewayNamespace);
+        JsonArray parentRefs = new JsonArray();
+        parentRefs.add(parentRef);
+
+        JsonObject backendRef = new JsonObject();
+        backendRef.addProperty("name", serviceName);
+        backendRef.addProperty("port", port);
+        JsonArray backendRefs = new JsonArray();
+        backendRefs.add(backendRef);
+
+        JsonObject pathMatch = new JsonObject();
+        pathMatch.addProperty("type", "PathPrefix");
+        pathMatch.addProperty("value", pathValue);
+        JsonObject match = new JsonObject();
+        match.add("path", pathMatch);
+        JsonArray matches = new JsonArray();
+        matches.add(match);
+
+        JsonObject rule = new JsonObject();
+        rule.add("backendRefs", backendRefs);
+        rule.add("matches", matches);
+        JsonArray rules = new JsonArray();
+        rules.add(rule);
+
+        JsonObject spec = new JsonObject();
+        spec.add("parentRefs", parentRefs);
+        spec.add("rules", rules);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "HTTPRoute");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteBackendHandlerTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteBackendHandlerTest.java
new file mode 100644
index 0000000000..d31399b3fc
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteBackendHandlerTest.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1EndpointsBuilder;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServiceBuilder;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.k8s.reconciler.HttpRouteEndpointsHandler;
+import org.apache.shenyu.k8s.reconciler.HttpRouteServiceHandler;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * The Service and Endpoints event handlers must both enqueue the HTTPRoutes referencing
+ * the changed backend through the shared backend-service index: a Service port or
+ * targetPort edit does not touch Endpoints, so without the Service handler routes keep
+ * the stale pod port until the periodic resync.
+ */
+public final class HttpRouteBackendHandlerTest {
+
+    private static final String NAMESPACE = "app-ns";
+
+    private static final String SERVICE_NAME = "backend-svc";
+
+    @SuppressWarnings("unchecked")
+    private SharedIndexInformer<DynamicKubernetesObject> mockRouteInformer(final Indexer<DynamicKubernetesObject> indexer) {
+        SharedIndexInformer<DynamicKubernetesObject> informer = mock(SharedIndexInformer.class);
+        when(informer.getIndexer()).thenReturn(indexer);
+        return informer;
+    }
+
+    /** A Service update must enqueue the routes whose backendRefs target the Service, resolved through the backend-service index. */
+    @Test
+    public void testServiceUpdateEnqueuesReferencingRoutes() {
+        Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+        when(indexer.getIndexers()).thenReturn(new HashMap<>(Map.of()));
+        when(indexer.byIndex("backendService", NAMESPACE + "/" + SERVICE_NAME))
+                .thenReturn(List.of(buildRoute("app-ns", "test-route", NAMESPACE, SERVICE_NAME)));
+        SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+        RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+        new HttpRouteServiceHandler(informer, queue).onUpdate(buildService(), buildService());
+
+        verify(queue).add(new Request("app-ns", "test-route"));
+    }
+
+    /** Endpoints events keep working through the same shared index. */
+    @Test
+    public void testEndpointsUpdateEnqueuesReferencingRoutes() {
+        Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+        when(indexer.getIndexers()).thenReturn(new HashMap<>(Map.of()));
+        when(indexer.byIndex("backendService", NAMESPACE + "/" + SERVICE_NAME))
+                .thenReturn(List.of(buildRoute("app-ns", "test-route", NAMESPACE, SERVICE_NAME)));
+        SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+        RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+        new HttpRouteEndpointsHandler(informer, queue).onUpdate(buildEndpoints(), buildEndpoints());
+
+        verify(queue).add(new Request("app-ns", "test-route"));
+    }
+
+    /** Both handlers share one informer index: the second registration must be a no-op. */
+    @Test
+    public void testSharedIndexRegisteredOnlyOnce() {
+        Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+        Map<String, Object> indexers = new HashMap<>();
+        when(indexer.getIndexers()).thenAnswer(inv -> new HashMap<>(indexers));
+        when(indexer.byIndex(anyString(), anyString())).thenReturn(List.of());
+        SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+        RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+        new HttpRouteEndpointsHandler(informer, queue);
+        // after the first handler registered the shared index it is already present
+        indexers.put("backendService", new Object());
+        new HttpRouteServiceHandler(informer, queue);
+
+        // the registration goes through the informer; the second handler must not repeat it
+        verify(informer, times(1)).addIndexers(any());
+    }
+
+    /** An event whose Service is referenced by no route enqueues nothing. */
+    @Test
+    public void testUnreferencedServiceEnqueuesNothing() {
+        Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+        when(indexer.getIndexers()).thenReturn(new HashMap<>(Map.of()));
+        when(indexer.byIndex(anyString(), anyString())).thenReturn(List.of());
+        SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+        RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+        new HttpRouteServiceHandler(informer, queue).onDelete(buildService(), false);
+
+        verify(queue, never()).add(any(Request.class));
+    }
+
+    /** Cross-namespace resolution: a route referencing Service {@code other-ns/svc} by explicit namespace is found when that Service changes. */
+    @Test
+    public void testCrossNamespaceBackendServiceResolved() {
+        Indexer<DynamicKubernetesObject> indexer = mock(Indexer.class);
+        when(indexer.getIndexers()).thenReturn(new HashMap<>(Map.of()));
+        when(indexer.byIndex("backendService", "other-ns/" + SERVICE_NAME))
+                .thenReturn(List.of(buildRoute("app-ns", "test-route", "other-ns", SERVICE_NAME)));
+        SharedIndexInformer<DynamicKubernetesObject> informer = mockRouteInformer(indexer);
+        RateLimitingQueue<Request> queue = mock(RateLimitingQueue.class);
+
+        V1Service otherNamespaceService = new V1ServiceBuilder()
+                .withNewMetadata().withNamespace("other-ns").withName(SERVICE_NAME).endMetadata().build();
+        new HttpRouteServiceHandler(informer, queue).onAdd(otherNamespaceService);
+
+        ArgumentCaptor<Request> captor = ArgumentCaptor.forClass(Request.class);
+        verify(queue).add(captor.capture());
+        Assertions.assertEquals("app-ns", captor.getValue().getNamespace());
+        Assertions.assertEquals("test-route", captor.getValue().getName());
+    }
+
+    private V1Service buildService() {
+        return new V1ServiceBuilder()
+                .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata().build();
+    }
+
+    private V1Endpoints buildEndpoints() {
+        return new V1EndpointsBuilder()
+                .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata().build();
+    }
+
+    private DynamicKubernetesObject buildRoute(final String routeNamespace, final String routeName,
+                                               final String serviceNamespace, final String serviceName) {
+        JsonObject backendRef = new JsonObject();
+        backendRef.addProperty("name", serviceName);
+        backendRef.addProperty("namespace", serviceNamespace);
+        backendRef.addProperty("port", 8080);
+        JsonArray backendRefs = new JsonArray();
+        backendRefs.add(backendRef);
+
+        JsonObject rule = new JsonObject();
+        rule.add("backendRefs", backendRefs);
+        JsonArray rules = new JsonArray();
+        rules.add(rule);
+
+        JsonObject spec = new JsonObject();
+        spec.add("rules", rules);
+
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", routeNamespace);
+        metadata.addProperty("name", routeName);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "HTTPRoute");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java
new file mode 100644
index 0000000000..5660d023f9
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/HttpRouteParserTest.java
@@ -0,0 +1,763 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s;
+
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.informer.cache.Indexer;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.models.V1EndpointAddress;
+import io.kubernetes.client.openapi.models.V1EndpointSubsetBuilder;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1EndpointsBuilder;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServiceBuilder;
+import io.kubernetes.client.openapi.models.V1ServicePortBuilder;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.common.dto.ConditionData;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.dto.convert.selector.DivideUpstream;
+import org.apache.shenyu.common.enums.OperatorEnum;
+import org.apache.shenyu.common.enums.ParamTypeEnum;
+import org.apache.shenyu.common.utils.GsonUtils;
+import org.apache.shenyu.k8s.common.IngressConfiguration;
+import org.apache.shenyu.k8s.common.ShenyuMemoryConfig;
+import org.apache.shenyu.k8s.parser.HttpRouteParser;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Objects;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/** HttpRouteParser Test. */
+public final class HttpRouteParserTest {
+
+    private static final String NAMESPACE = "test-ns";
+
+    private static final String SERVICE_NAME = "test-service";
+
+    private static final int SERVICE_PORT = 8189;
+
+    /**
+     * Test parse with path prefix match: the spec requires element-boundary matching
+     * ({@code /api} matches {@code /api} and {@code /api/x} but not {@code /apix}), so the
+     * prefix maps to an anchored regex, not a raw startsWith. Longer prefixes must sort
+     * lower (higher precedence) than shorter ones and any exact path must outrank a prefix.
+     */
+    @Test
+    public void testParseWithPathPrefix() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/api", "PathPrefix", null, null);
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        List<SelectorData> selectors = extractSelectors(config);
+        List<RuleData> rules = extractRules(config);
+        Assertions.assertEquals(1, selectors.size());
+        Assertions.assertEquals(1, rules.size());
+
+        SelectorData selector = config.getRouteConfigList().get(0).getSelectorData();
+        ConditionData pathCondition = firstRealCondition(selector.getConditionList());
+        Assertions.assertEquals(ParamTypeEnum.URI.getName(), pathCondition.getParamType());
+        Assertions.assertEquals(OperatorEnum.REGEX.getAlias(), pathCondition.getOperator());
+        Assertions.assertEquals("^\\Q/api\\E(/.*)?$", pathCondition.getParamValue());
+        // precedence score = pathScore("/api" prefix) = 2 + 3 = 5; lower sort wins
+        Assertions.assertEquals((1 << 28) - (5 << 13) - 15, selector.getSort());
+    }
+
+    /** Test parse with exact path match: EQ operator and the highest path precedence, outranking any prefix/regex/no-path match per the spec precedence rules. */
+    @Test
+    public void testParseWithExactPath() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/api/v1/test", "Exact", null, null);
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        SelectorData selector = config.getRouteConfigList().get(0).getSelectorData();
+        ConditionData pathCondition = firstRealCondition(selector.getConditionList());
+        Assertions.assertEquals(OperatorEnum.EQ.getAlias(), pathCondition.getOperator());
+        Assertions.assertEquals((1 << 28) - (2047 << 13) - 15, selector.getSort());
+    }
+
+    /** A rule without matches is the catch-all of the spec (equivalent to PathPrefix /). */
+    @Test
+    public void testParseWithRuleWithoutMatchesGetsMatchAllCondition() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/api", "PathPrefix", null, null);
+        httpRoute.getRaw().getAsJsonObject("spec")
+                .getAsJsonArray("rules").get(0).getAsJsonObject()
+                .remove("matches");
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        Assertions.assertEquals(1, config.getRouteConfigList().size());
+        SelectorData selector = config.getRouteConfigList().get(0).getSelectorData();
+        for (ConditionData condition : selector.getConditionList()) {
+            Assertions.assertEquals(ParamTypeEnum.URI.getName(), condition.getParamType());
+            Assertions.assertEquals(OperatorEnum.STARTS_WITH.getAlias(), condition.getOperator());
+            Assertions.assertEquals("/", condition.getParamValue());
+        }
+        // the rule's conditions stay unpadded: exactly the one match-all
+        Assertions.assertEquals(1, config.getRouteConfigList().get(0).getRuleDataList().get(0)
+                .getConditionDataList().size());
+    }
+
+    /** Every generated selector is padded to one fixed condition count so the data plane's condition-count-first tie-breaking cannot override the precedence-encoded sort. */
+    @Test
+    public void testSelectorConditionListsPaddedToFloor() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRouteWithHostnames(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", new String[]{"example.com", "api.example.com"});
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of("example.com", "api.example.com"));
+
+        for (IngressConfiguration routeConfig : config.getRouteConfigList()) {
+            Assertions.assertEquals(16, routeConfig.getSelectorData().getConditionList().size(),
+                    "selector condition lists must all be padded to the fixed floor");
+        }
+    }
+
+    /**
+     * Gateway API precedence: for equal paths, a match carrying an HTTP method outranks a
+     * match with more header conditions — the sort must encode this independently of the
+     * raw condition count (which the padding equalizes anyway).
+     */
+    @Test
+    public void testMethodPresenceOutranksHeaderCount() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        final HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/api", "PathPrefix", "X-Header", "test-value");
+        final JsonArray matches = httpRoute.getRaw().getAsJsonObject("spec")
+                .getAsJsonArray("rules").get(0).getAsJsonObject()
+                .getAsJsonArray("matches");
+        JsonObject secondHeader = new JsonObject();
+        secondHeader.addProperty("name", "X-Other");
+        secondHeader.addProperty("value", "other");
+        secondHeader.addProperty("type", "Exact");
+        matches.get(0).getAsJsonObject().getAsJsonArray("headers").add(secondHeader);
+        JsonObject methodMatchPath = new JsonObject();
+        methodMatchPath.addProperty("type", "PathPrefix");
+        methodMatchPath.addProperty("value", "/api");
+        JsonObject methodMatch = new JsonObject();
+        methodMatch.add("path", methodMatchPath);
+        methodMatch.addProperty("method", "GET");
+        matches.add(methodMatch);
+
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+        List<SelectorData> selectors = extractSelectors(config);
+        Assertions.assertEquals(2, selectors.size());
+        SelectorData methodSelector = selectors.stream()
+                .filter(s -> s.getConditionList().stream().anyMatch(c -> ParamTypeEnum.REQUEST_METHOD.getName().equals(c.getParamType())))
+                .findFirst().orElseThrow();
+        SelectorData headerSelector = selectors.stream()
+                .filter(s -> s.getConditionList().stream().filter(c -> ParamTypeEnum.HEADER.getName().equals(c.getParamType())).count() == 2)
+                .findFirst().orElseThrow();
+        // lower sort wins on the data plane
+        Assertions.assertTrue(methodSelector.getSort() < headerSelector.getSort(),
+                "a method match must outrank a two-header match for equal paths");
+    }
+
+    @Test
+    public void testFirstRuleWinsFullPrecedenceTie() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/api", "PathPrefix", null, null);
+        httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules")
+                .add(httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules")
+                        .get(0).getAsJsonObject().deepCopy());
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+        List<SelectorData> selectors = extractSelectors(config);
+        Assertions.assertEquals(2, selectors.size());
+        Assertions.assertTrue(selectors.get(0).getSort() < selectors.get(1).getSort(),
+                "the first rule must win a full precedence tie");
+    }
+
+    @Test
+    public void testLongerPrefixOutranksBeyondTwelveCharacters() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/verylongapiprefix", "PathPrefix", null, null);
+        JsonObject longerRule = httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules")
+                .get(0).getAsJsonObject().deepCopy();
+        longerRule.getAsJsonArray("matches").get(0).getAsJsonObject()
+                .getAsJsonObject("path").addProperty("value", "/verylongapiprefix/more");
+        httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules").add(longerRule);
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+        List<SelectorData> selectors = extractSelectors(config);
+        Assertions.assertEquals(2, selectors.size());
+        Assertions.assertTrue(selectors.get(1).getSort() < selectors.get(0).getSort(),
+                "the longer prefix must outrank the shorter one beyond the old 12-char cap");
+    }
+
+    /** Test parse with hostname conditions. Each hostname should generate a separate selector+rule (one hostname per selector). */
+    @Test
+    public void testParseWithHostnames() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRouteWithHostnames(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", new String[]{"example.com", "api.example.com"});
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of("example.com", "api.example.com"));
+
+        List<IngressConfiguration> routeConfigs = config.getRouteConfigList();
+        Assertions.assertEquals(2, routeConfigs.size());
+
+        for (IngressConfiguration routeConfig : routeConfigs) {
+            List<ConditionData> conditions = routeConfig.getSelectorData().getConditionList();
+            long hostConditions = conditions.stream()
+                    .filter(c -> ParamTypeEnum.DOMAIN.getName().equals(c.getParamType()))
+                    .count();
+            Assertions.assertEquals(1, hostConditions);
+
+            long pathConditions = conditions.stream()
+                    .filter(c -> ParamTypeEnum.URI.getName().equals(c.getParamType()))
+                    .filter(c -> !isPadding(c))
+                    .count();
+            Assertions.assertEquals(1, pathConditions);
+        }
+    }
+
+    /**
+     * Test that exact hostnames use EQ and wildcard hostnames use REGEX with an anchored
+     * suffix-match regex: per the Gateway API spec {@code *.example.com} matches
+     * {@code test.example.com} and {@code foo.test.example.com} but not {@code example.com}.
+     */
+    @Test
+    public void testParseWithWildcardHostname() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRouteWithHostnames(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", new String[]{"*.example.com"});
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of("*.example.com"));
+
+        List<ConditionData> domainConditions = config.getRouteConfigList().stream()
+                .flatMap(rc -> rc.getSelectorData().getConditionList().stream())
+                .filter(c -> ParamTypeEnum.DOMAIN.getName().equals(c.getParamType()))
+                .toList();
+        Assertions.assertEquals(1, domainConditions.size());
+
+        ConditionData wildcardCondition = domainConditions.get(0);
+        Assertions.assertEquals(OperatorEnum.REGEX.getAlias(), wildcardCondition.getOperator());
+        Assertions.assertEquals("^([^.]+\\.)+example\\.com$", wildcardCondition.getParamValue());
+    }
+
+    /** Test parse with header match. */
+    @Test
+    public void testParseWithHeaderMatch() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", "X-Custom-Header", "test-value");
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        List<ConditionData> conditions = config.getRouteConfigList().get(0).getSelectorData().getConditionList();
+        long headerConditions = conditions.stream()
+                .filter(c -> ParamTypeEnum.HEADER.getName().equals(c.getParamType()))
+                .count();
+        Assertions.assertEquals(1, headerConditions);
+
+        ConditionData headerCondition = conditions.stream()
+                .filter(c -> ParamTypeEnum.HEADER.getName().equals(c.getParamType()))
+                .findFirst().orElse(null);
+        Assertions.assertNotNull(headerCondition);
+        Assertions.assertEquals("X-Custom-Header", headerCondition.getParamName());
+        Assertions.assertEquals("test-value", headerCondition.getParamValue());
+        Assertions.assertEquals(OperatorEnum.EQ.getAlias(), headerCondition.getOperator());
+    }
+
+    /** A method match must produce a req_method/EQ condition instead of being silently dropped: dropping it would widen the selector to match requests of any method. */
+    @Test
+    public void testParseWithMethodMatch() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", null, null);
+        httpRoute.getRaw().getAsJsonObject("spec")
+                .getAsJsonArray("rules").get(0).getAsJsonObject()
+                .getAsJsonArray("matches").get(0).getAsJsonObject()
+                .addProperty("method", "GET");
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        ConditionData methodCondition = config.getRouteConfigList().get(0).getSelectorData().getConditionList().stream()
+                .filter(c -> ParamTypeEnum.REQUEST_METHOD.getName().equals(c.getParamType()))
+                .findFirst().orElse(null);
+        Assertions.assertNotNull(methodCondition);
+        Assertions.assertEquals(OperatorEnum.EQ.getAlias(), methodCondition.getOperator());
+        Assertions.assertEquals("GET", methodCondition.getParamValue());
+    }
+
+    /** Test parse with query param match. */
+    @Test
+    public void testParseWithQueryParam() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRouteWithQueryParams(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", "debug", "true", "Exact");
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        List<ConditionData> conditions = config.getRouteConfigList().get(0).getSelectorData().getConditionList();
+        long queryConditions = conditions.stream()
+                .filter(c -> ParamTypeEnum.QUERY.getName().equals(c.getParamType()))
+                .count();
+        Assertions.assertEquals(1, queryConditions);
+
+        ConditionData queryCondition = conditions.stream()
+                .filter(c -> ParamTypeEnum.QUERY.getName().equals(c.getParamType()))
+                .findFirst().orElse(null);
+        Assertions.assertNotNull(queryCondition);
+        Assertions.assertEquals("debug", queryCondition.getParamName());
+        Assertions.assertEquals("true", queryCondition.getParamValue());
+    }
+
+    /** A backendRef with an explicit weight mixed with one that omits it must resolve to the spec default 1 (9:1), not to an arbitrary parser default like 100 (9:100). */
+    @Test
+    public void testMixedExplicitAndDefaultBackendRefWeights() {
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", null, null);
+        JsonArray backendRefs = httpRoute.getRaw().getAsJsonObject("spec")
+                .getAsJsonArray("rules").get(0).getAsJsonObject().getAsJsonArray("backendRefs");
+        backendRefs.get(0).getAsJsonObject().addProperty("weight", 9);
+        JsonObject unweighted = new JsonObject();
+        unweighted.addProperty("name", SERVICE_NAME);
+        unweighted.addProperty("port", 8080);
+        backendRefs.add(unweighted);
+
+        HttpRouteParser parser = new HttpRouteParser(mockEndpointsLister(), mockServiceLister(), mockReferenceGrantLister());
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        List<SelectorData> selectors = extractSelectors(config);
+        Assertions.assertEquals(1, selectors.size());
+
+        List<DivideUpstream> upstreams = GsonUtils.getInstance()
+                .fromList(selectors.get(0).getHandle(), DivideUpstream.class);
+        // Each backendRef fans out to the 2 addresses of the mocked Endpoints. The common
+        // scale factor is 2 (lifts the weight-1 backend's per-endpoint share to 1), so the
+        // per-endpoint weights are 9 and 1 and the backend totals keep the declared 9:1
+        // ratio (18:2) regardless of replica counts.
+        Assertions.assertEquals(4, upstreams.size());
+        Assertions.assertEquals(9, upstreams.get(0).getWeight());
+        Assertions.assertEquals(9, upstreams.get(1).getWeight());
+        Assertions.assertEquals(1, upstreams.get(2).getWeight());
+        Assertions.assertEquals(1, upstreams.get(3).getWeight());
+    }
+
+    /** The root prefix {@code /} is the spec's catch-all and must match every absolute path. */
+    @Test
+    public void testParseWithRootPathPrefixMatchesEveryPath() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/", "PathPrefix", null, null);
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        ConditionData pathCondition = config.getRouteConfigList().get(0).getSelectorData().getConditionList().get(0);
+        Assertions.assertEquals(OperatorEnum.REGEX.getAlias(), pathCondition.getOperator());
+        Assertions.assertEquals("^/.*$", pathCondition.getParamValue());
+        Assertions.assertTrue(java.util.regex.Pattern.compile(pathCondition.getParamValue()).matcher("/order").matches(),
+                "root prefix must match ordinary paths like /order");
+    }
+
+    /** A backendRef whose kind is Service but whose group names a foreign API group must be reported InvalidKind, not resolved against an unrelated core Service of the same name. */
+    @Test
+    public void testBackendRefWithForeignGroupIsInvalidKind() {
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", null, null);
+        httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules").get(0).getAsJsonObject()
+                .getAsJsonArray("backendRefs").get(0).getAsJsonObject().addProperty("group", "example.com");
+
+        HttpRouteParser parser = new HttpRouteParser(mockEndpointsLister(), mockServiceLister(), mockReferenceGrantLister());
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        Assertions.assertFalse(config.isAllBackendsResolved());
+        Assertions.assertEquals("InvalidKind", config.getUnresolvedReason());
+    }
+
+    /**
+     * The spec's 500-for-invalid-share rule: a rule mixing one valid and one invalid
+     * (default weight 1 each) backendRef keeps the valid upstreams routable and adds a
+     * fail-target upstream carrying exactly the invalid share — here 50/50, per the spec's
+     * "two equal backends, one invalid, 50 percent must receive a 500" example.
+     */
+    @Test
+    public void testInvalidWeightedShareBecomesFailTargetNotFailClosed() {
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", null, null);
+        JsonObject brokenBackend = new JsonObject();
+        brokenBackend.addProperty("name", "missing-service");
+        brokenBackend.addProperty("port", 8080);
+        httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules").get(0).getAsJsonObject()
+                .getAsJsonArray("backendRefs").add(brokenBackend);
+
+        // first backend resolves to 10.0.0.1/2, second has no endpoints
+        HttpRouteParser parser = new HttpRouteParser(mockEndpointsLister(), mockServiceLister(), mockReferenceGrantLister());
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        Assertions.assertFalse(config.isAllBackendsResolved());
+        List<SelectorData> selectors = extractSelectors(config);
+        Assertions.assertEquals(1, selectors.size(), "mixed rule must still program its selector");
+        List<DivideUpstream> upstreams = GsonUtils.getInstance()
+                .fromList(selectors.get(0).getHandle(), DivideUpstream.class);
+        Assertions.assertEquals(3, upstreams.size());
+
+        List<DivideUpstream> valid = upstreams.stream()
+                .filter(u -> !"127.0.0.1:1".equals(u.getUpstreamUrl())).toList();
+        List<DivideUpstream> failTargets = upstreams.stream()
+                .filter(u -> "127.0.0.1:1".equals(u.getUpstreamUrl())).toList();
+        Assertions.assertEquals(2, valid.size(), "valid backends must stay routable");
+        Assertions.assertEquals(1, failTargets.size(), "the invalid share must map to one fail target");
+
+        int validWeight = valid.stream().mapToInt(DivideUpstream::getWeight).sum();
+        Assertions.assertEquals(validWeight, failTargets.get(0).getWeight(),
+                "equal weights (1:1) must split traffic 50/50 between valid and fail target");
+    }
+
+    /**
+     * When every weighted backendRef is invalid the fail target carries the whole traffic:
+     * all matching requests receive a 500 (spec: all-invalid rule with no filters MUST
+     * return 500) instead of the selector silently matching nothing.
+     */
+    @Test
+    public void testAllBackendsInvalidRoutesWholeShareToFailTarget() {
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", null, null);
+        httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules").get(0).getAsJsonObject()
+                .getAsJsonArray("backendRefs").get(0).getAsJsonObject()
+                .addProperty("name", "missing-service");
+
+        HttpRouteParser parser = new HttpRouteParser(mockEndpointsLister(), mockServiceLister(), mockReferenceGrantLister());
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        Assertions.assertFalse(config.isAllBackendsResolved());
+        List<DivideUpstream> upstreams = GsonUtils.getInstance()
+                .fromList(extractSelectors(config).get(0).getHandle(), DivideUpstream.class);
+        Assertions.assertEquals(1, upstreams.size());
+        Assertions.assertEquals("127.0.0.1:1", upstreams.get(0).getUpstreamUrl());
+    }
+
+    /** A multi-port Service is resolved through its spec: the backendRef port selects spec.ports[], whose numeric targetPort is the pod port. */
+    @Test
+    public void testServiceTargetPortMapping() {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints endpoints = new V1EndpointsBuilder()
+                .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder()
+                        .withAddresses(new V1EndpointAddress().ip("10.0.0.1"))
+                        .addNewPort().withName("web").withPort(8080).and()
+                        .addNewPort().withName("tls").withPort(8443).and()
+                        .build())
+                .build();
+        when(endpointsIndexer.getByKey(NAMESPACE + "/" + SERVICE_NAME)).thenReturn(endpoints);
+
+        Indexer<V1Service> serviceIndexer = mock(Indexer.class);
+        V1Service service = new V1ServiceBuilder()
+                .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata()
+                .withNewSpec()
+                .addToPorts(new V1ServicePortBuilder().withName("http").withPort(80)
+                        .withNewTargetPort(8080).build())
+                .addToPorts(new V1ServicePortBuilder().withName("https").withPort(443)
+                        .withNewTargetPort(8443).build())
+                .endSpec()
+                .build();
+        when(serviceIndexer.getByKey(NAMESPACE + "/" + SERVICE_NAME)).thenReturn(service);
+
+        HttpRouteParser parser = new HttpRouteParser(new Lister<>(endpointsIndexer),
+                new Lister<>(serviceIndexer), mockReferenceGrantLister());
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, 80,
+                "/**", "PathPrefix", null, null);
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        Assertions.assertTrue(config.isAllBackendsResolved());
+        List<DivideUpstream> upstreams = GsonUtils.getInstance()
+                .fromList(extractSelectors(config).get(0).getHandle(), DivideUpstream.class);
+        Assertions.assertEquals(1, upstreams.size());
+        Assertions.assertEquals("10.0.0.1:8080", upstreams.get(0).getUpstreamUrl(),
+                "service port 80 must map to its targetPort 8080");
+    }
+
+    /** Deterministic ID: parsing the same HTTPRoute twice must yield identical selector/rule IDs. */
+    @Test
+    public void testSelectorAndRuleIdsAreStableAcrossParses() {
+        Lister<V1Endpoints> endpointsLister = mockEndpointsLister();
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), mockReferenceGrantLister());
+
+        DynamicKubernetesObject httpRoute = buildHTTPRouteWithHostnames(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/**", "PathPrefix", new String[]{"example.com", "api.example.com"});
+
+        ShenyuMemoryConfig first = parser.parse(httpRoute, List.of("example.com", "api.example.com"));
+        List<String> firstSelectorIds = first.getRouteConfigList().stream()
+                .map(rc -> rc.getSelectorData().getId()).toList();
+        List<String> firstRuleIds = first.getRouteConfigList().stream()
+                .flatMap(rc -> rc.getRuleDataList().stream()).map(r -> r.getId()).toList();
+
+        ShenyuMemoryConfig second = parser.parse(httpRoute, List.of("example.com", "api.example.com"));
+        List<String> secondSelectorIds = second.getRouteConfigList().stream()
+                .map(rc -> rc.getSelectorData().getId()).toList();
+        List<String> secondRuleIds = second.getRouteConfigList().stream()
+                .flatMap(rc -> rc.getRuleDataList().stream()).map(r -> r.getId()).toList();
+
+        Assertions.assertEquals(firstSelectorIds, secondSelectorIds, "selector IDs must be deterministic");
+        Assertions.assertEquals(firstRuleIds, secondRuleIds, "rule IDs must be deterministic");
+    }
+
+    private Lister<V1Endpoints> mockEndpointsLister() {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints mockedEndpoints = new V1EndpointsBuilder().withKind("Endpoints")
+                .withNewMetadata().withNamespace(NAMESPACE).withName(SERVICE_NAME).endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder()
+                        .withAddresses(new V1EndpointAddress().ip("10.0.0.1"), new V1EndpointAddress().ip("10.0.0.2"))
+                        .build())
+                .build();
+        when(endpointsIndexer.getByKey(NAMESPACE + "/" + SERVICE_NAME)).thenReturn(mockedEndpoints);
+        return new Lister<>(endpointsIndexer);
+    }
+
+    /** Empty Service lister: the parser falls back to the Endpoints-only port heuristic, which is what the pre-existing parser tests exercise. */
+    private Lister<V1Service> mockServiceLister() {
+        return new Lister<>(mock(Indexer.class));
+    }
+
+    /** Empty ReferenceGrant lister: same-namespace backendRefs never consult grants, so all existing parser tests are unaffected by grant matching. */
+    private Lister<DynamicKubernetesObject> mockReferenceGrantLister() {
+        return new Lister<>(mock(Indexer.class));
+    }
+
+    /** Cross-namespace backendRef WITH a matching ReferenceGrant (to Service in the core group) resolves normally. */
+    @Test
+    public void testCrossNamespaceBackendRefWithGrantResolves() {
+        Indexer<V1Endpoints> endpointsIndexer = mock(Indexer.class);
+        V1Endpoints endpoints = new V1EndpointsBuilder()
+                .withNewMetadata().withNamespace("other-ns").withName(SERVICE_NAME).endMetadata()
+                .withSubsets(new V1EndpointSubsetBuilder().withAddresses(new V1EndpointAddress().ip("10.0.0.1")).build())
+                .build();
+        when(endpointsIndexer.getByKey("other-ns/" + SERVICE_NAME)).thenReturn(endpoints);
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsIndexer);
+
+        Indexer<DynamicKubernetesObject> grantIndexer = mock(Indexer.class);
+        when(grantIndexer.byIndex("namespace", "other-ns")).thenReturn(List.of(buildServiceGrant("other-ns", NAMESPACE, null)));
+        HttpRouteParser parser = new HttpRouteParser(endpointsLister, mockServiceLister(), new Lister<>(grantIndexer));
+
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(NAMESPACE, "test-route",
+                NAMESPACE, "shenyu-gateway", SERVICE_NAME, SERVICE_PORT,
+                "/api/**", "PathPrefix", null, null);
+        JsonObject backendRef = httpRoute.getRaw().getAsJsonObject("spec")
+                .getAsJsonArray("rules").get(0).getAsJsonObject()
+                .getAsJsonArray("backendRefs").get(0).getAsJsonObject();
+        backendRef.addProperty("namespace", "other-ns");
+
+        ShenyuMemoryConfig config = parser.parse(httpRoute, List.of());
+
+        Assertions.assertTrue(config.isAllBackendsResolved());
+        Assertions.assertEquals(1, extractSelectors(config).size());
+    }
+
+    /**
+     * Build a ReferenceGrant allowing an HTTPRoute from {@code fromNamespace} to
+     * reference a Service (core group, group="") in {@code namespace}, optionally
+     * restricted to {@code toName}.
+     */
+    private DynamicKubernetesObject buildServiceGrant(final String namespace, final String fromNamespace,
+                                                      final String toName) {
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", namespace);
+        metadata.addProperty("name", "allow-services");
+
+        JsonObject from = new JsonObject();
+        from.addProperty("group", "gateway.networking.k8s.io");
+        from.addProperty("kind", "HTTPRoute");
+        from.addProperty("namespace", fromNamespace);
+        JsonArray fromArray = new JsonArray();
+        fromArray.add(from);
+
+        JsonObject to = new JsonObject();
+        to.addProperty("group", "");
+        to.addProperty("kind", "Service");
+        if (Objects.nonNull(toName)) {
+            to.addProperty("name", toName);
+        }
+        JsonArray toArray = new JsonArray();
+        toArray.add(to);
+
+        JsonObject spec = new JsonObject();
+        spec.add("from", fromArray);
+        spec.add("to", toArray);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1beta1");
+        raw.addProperty("kind", "ReferenceGrant");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+
+    private List<SelectorData> extractSelectors(final ShenyuMemoryConfig config) {
+        return config.getRouteConfigList().stream().map(r -> r.getSelectorData()).toList();
+    }
+
+    /** Padding conditions appended to reach the fixed selector condition count. */
+    private boolean isPadding(final ConditionData condition) {
+        return ParamTypeEnum.URI.getName().equals(condition.getParamType())
+                && OperatorEnum.STARTS_WITH.getAlias().equals(condition.getOperator())
+                && "/".equals(condition.getParamValue());
+    }
+
+    /** Real (non-padding) conditions come first; padding is only appended at the end. */
+    private ConditionData firstRealCondition(final List<ConditionData> conditions) {
+        return conditions.stream().filter(c -> !isPadding(c)).findFirst().orElseThrow();
+    }
+
+    private List<RuleData> extractRules(final ShenyuMemoryConfig config) {
+        return config.getRouteConfigList().stream().flatMap(r -> r.getRuleDataList().stream()).toList();
+    }
+
+    private DynamicKubernetesObject buildHTTPRoute(final String routeNamespace, final String routeName,
+                                                    final String gatewayNamespace, final String gatewayName,
+                                                    final String serviceName, final int port,
+                                                    final String pathValue, final String pathType,
+                                                    final String headerName, final String headerValue) {
+        JsonObject metadata = new JsonObject();
+        metadata.addProperty("namespace", routeNamespace);
+        metadata.addProperty("name", routeName);
+
+        JsonObject parentRef = new JsonObject();
+        parentRef.addProperty("name", gatewayName);
+        parentRef.addProperty("namespace", gatewayNamespace);
+        JsonArray parentRefs = new JsonArray();
+        parentRefs.add(parentRef);
+
+        JsonObject backendRef = new JsonObject();
+        backendRef.addProperty("name", serviceName);
+        backendRef.addProperty("port", port);
+        JsonArray backendRefs = new JsonArray();
+        backendRefs.add(backendRef);
+
+        JsonObject pathMatch = new JsonObject();
+        pathMatch.addProperty("type", pathType);
+        pathMatch.addProperty("value", pathValue);
+        JsonObject match = new JsonObject();
+        match.add("path", pathMatch);
+
+        if (Objects.nonNull(headerName) && Objects.nonNull(headerValue)) {
+            JsonObject header = new JsonObject();
+            header.addProperty("name", headerName);
+            header.addProperty("value", headerValue);
+            header.addProperty("type", "Exact");
+            JsonArray headers = new JsonArray();
+            headers.add(header);
+            match.add("headers", headers);
+        }
+
+        JsonArray matches = new JsonArray();
+        matches.add(match);
+
+        JsonObject rule = new JsonObject();
+        rule.add("backendRefs", backendRefs);
+        rule.add("matches", matches);
+        JsonArray rules = new JsonArray();
+        rules.add(rule);
+
+        JsonObject spec = new JsonObject();
+        spec.add("parentRefs", parentRefs);
+        spec.add("rules", rules);
+
+        JsonObject raw = new JsonObject();
+        raw.addProperty("apiVersion", "gateway.networking.k8s.io/v1");
+        raw.addProperty("kind", "HTTPRoute");
+        raw.add("metadata", metadata);
+        raw.add("spec", spec);
+        return new DynamicKubernetesObject(raw);
+    }
+
+    private DynamicKubernetesObject buildHTTPRouteWithHostnames(final String routeNamespace, final String routeName,
+                                                                 final String gatewayNamespace, final String gatewayName,
+                                                                 final String serviceName, final int port,
+                                                                 final String pathValue, final String pathType,
+                                                                 final String[] hostnames) {
+        DynamicKubernetesObject httpRoute = buildHTTPRoute(routeNamespace, routeName,
+                gatewayNamespace, gatewayName, serviceName, port, pathValue, pathType, null, null);
+
+        JsonArray hostnameArray = new JsonArray();
+        for (String hostname : hostnames) {
+            hostnameArray.add(hostname);
+        }
+        httpRoute.getRaw().getAsJsonObject("spec").add("hostnames", hostnameArray);
+        return httpRoute;
+    }
+
+    private DynamicKubernetesObject buildHTTPRouteWithQueryParams(final String routeNamespace, final String routeName,
+                                                                   final String gatewayNamespace, final String gatewayName,
+                                                                   final String serviceName, final int port,
+                                                                   final String pathValue, final String pathType,
+                                                                   final String queryName, final String queryValue,
+                                                                   final String queryType) {
+        JsonObject queryParam = new JsonObject();
+        queryParam.addProperty("name", queryName);
+        queryParam.addProperty("value", queryValue);
+        queryParam.addProperty("type", queryType);
+        JsonArray queryParams = new JsonArray();
+        queryParams.add(queryParam);
+
+        final DynamicKubernetesObject httpRoute = buildHTTPRoute(routeNamespace, routeName,
+                gatewayNamespace, gatewayName, serviceName, port, pathValue, pathType, null, null);
+        JsonArray rules = httpRoute.getRaw().getAsJsonObject("spec").getAsJsonArray("rules");
+        JsonObject firstRule = rules.get(0).getAsJsonObject();
+        JsonObject firstMatch = firstRule.getAsJsonArray("matches").get(0).getAsJsonObject();
+        firstMatch.add("queryParams", queryParams);
+
+        return httpRoute;
+    }
+
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/K8sCacheReadinessTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/K8sCacheReadinessTest.java
new file mode 100644
index 0000000000..d1bedb265f
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/K8sCacheReadinessTest.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s;
+
+import io.kubernetes.client.extended.workqueue.WorkQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import org.apache.shenyu.k8s.cache.K8sCacheReadiness;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Readiness must wait for the initial reconciliation backlog to drain in addition to the
+ * informer sync, and must observe the queues empty twice in a row to close the race of an
+ * item popped but not yet reconciled.
+ */
+public final class K8sCacheReadinessTest {
+
+    @Test
+    public void testNotReadyWhileInformerUnsynced() {
+        SharedIndexInformer<?> informer = mock(SharedIndexInformer.class);
+        when(informer.hasSynced()).thenReturn(false);
+        K8sCacheReadiness readiness = new K8sCacheReadiness(List.of(informer), List.of());
+
+        Assertions.assertFalse(readiness.isReady());
+        Assertions.assertEquals(1L, readiness.pendingInformers());
+    }
+
+    @Test
+    public void testNotReadyWhileWorkQueuesHoldItems() {
+        K8sCacheReadiness readiness = readiness(true, 3);
+
+        Assertions.assertFalse(readiness.isReady());
+        Assertions.assertEquals(3L, readiness.pendingWorkItems());
+    }
+
+    @Test
+    public void testReadyOnlyAfterRepeatedDrainedObservations() {
+        SharedIndexInformer<?> informer = syncedInformer();
+        WorkQueue<?> queue = queue(0);
+        K8sCacheReadiness readiness = new K8sCacheReadiness(List.of(informer), List.of(queue));
+
+        Assertions.assertFalse(readiness.isReady(), "a single drained observation must not flip readiness");
+        Assertions.assertTrue(readiness.isReady(), "the second consecutive drained observation must");
+        // readiness latches: a later refill (e.g. resync) does not flip it back
+        when(queue.length()).thenReturn(5);
+        Assertions.assertTrue(readiness.isReady());
+    }
+
+    @Test
+    public void testRefillResetsTheDrainedStreak() {
+        SharedIndexInformer<?> informer = syncedInformer();
+        WorkQueue<?> queue = queue(0);
+        K8sCacheReadiness readiness = new K8sCacheReadiness(List.of(informer), List.of(queue));
+
+        Assertions.assertFalse(readiness.isReady());
+        when(queue.length()).thenReturn(2);
+        Assertions.assertFalse(readiness.isReady());
+        when(queue.length()).thenReturn(0);
+        Assertions.assertFalse(readiness.isReady(), "streak was reset by the refill");
+        Assertions.assertTrue(readiness.isReady());
+    }
+
+    private K8sCacheReadiness readiness(final boolean synced, final int queueLength) {
+        return new K8sCacheReadiness(List.of(syncedInformer()), List.of(queue(queueLength)));
+    }
+
+    private SharedIndexInformer<?> syncedInformer() {
+        SharedIndexInformer<?> informer = mock(SharedIndexInformer.class);
+        when(informer.hasSynced()).thenReturn(true);
+        return informer;
+    }
+
+    private WorkQueue<?> queue(final int length) {
+        WorkQueue<?> queue = mock(WorkQueue.class);
+        when(queue.length()).thenReturn(length);
+        return queue;
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/ShenyuCacheRepositoryTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/ShenyuCacheRepositoryTest.java
new file mode 100644
index 0000000000..04712cb7e2
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/ShenyuCacheRepositoryTest.java
@@ -0,0 +1,169 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s;
+
+import org.apache.shenyu.common.config.ShenyuConfig;
+import org.apache.shenyu.common.dto.RuleData;
+import org.apache.shenyu.common.dto.SelectorData;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.loadbalancer.cache.UpstreamCacheManager;
+import org.apache.shenyu.loadbalancer.entity.Upstream;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.apache.shenyu.plugin.base.cache.BaseDataCache;
+import org.apache.shenyu.plugin.base.cache.CommonDiscoveryUpstreamDataSubscriber;
+import org.apache.shenyu.plugin.base.cache.CommonPluginDataSubscriber;
+import org.apache.shenyu.plugin.divide.handler.DivideUpstreamDataHandler;
+import org.apache.shenyu.plugin.global.subsciber.MetaDataCacheSubscriber;
+import org.apache.shenyu.sync.data.api.MetaDataSubscriber;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.mockito.Mockito;
+
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Regression tests for upstream snapshot replacement in ShenyuCacheRepository.
+ *
+ * <p>UpstreamCacheManager.submit merges by URL (removal only for entries explicitly marked
+ * offline), so a full-snapshot save whose upstream set shrank must first drop the cached
+ * list, otherwise a scaled-down backend keeps receiving traffic forever.
+ */
+public final class ShenyuCacheRepositoryTest {
+
+    private static final String SELECTOR_ID = "gwapi-upstream-snapshot-test";
+
+    private final UpstreamCacheManager upstreamCacheManager = UpstreamCacheManager.getInstance();
+
+    @AfterEach
+    public void tearDown() {
+        upstreamCacheManager.removeByKey(SELECTOR_ID);
+    }
+
+    private ShenyuCacheRepository repository() {
+        CommonDiscoveryUpstreamDataSubscriber discoverySubscriber =
+                new CommonDiscoveryUpstreamDataSubscriber(List.of(new DivideUpstreamDataHandler()));
+        return new ShenyuCacheRepository(Mockito.mock(CommonPluginDataSubscriber.class), discoverySubscriber,
+                Mockito.mock(MetaDataSubscriber.class), Mockito.mock(MetaDataCacheSubscriber.class));
+    }
+
+    private Upstream upstream(final String url) {
+        return Upstream.builder().url(url).weight(50).warmup(0).protocol("http://")
+                .healthCheckEnabled(false).status(true).build();
+    }
+
+    private SelectorData selector(final String handle) {
+        return SelectorData.builder()
+                .id(SELECTOR_ID)
+                .name("demo-route")
+                .pluginName(PluginEnum.DIVIDE.getName())
+                .handle(handle)
+                .build();
+    }
+
+    private String handleOf(final String... urls) {
+        StringBuilder handle = new StringBuilder("[");
+        for (int i = 0; i < urls.length; i++) {
+            if (i > 0) {
+                handle.append(",");
+            }
+            handle.append("{\"weight\":50,\"warmup\":0,\"protocol\":\"http://\",\"upstreamHost\":\"\",")
+                    .append("\"upstreamUrl\":\"").append(urls[i]).append("\",\"status\":true,\"timestamp\":0}");
+        }
+        return handle.append("]").toString();
+    }
+
+    @Test
+    public void testScaledDownUpstreamIsRemovedFromCache() {
+        upstreamCacheManager.submit(SELECTOR_ID, List.of(upstream("10.0.0.1:8189"), upstream("10.0.0.2:8189")));
+        Assertions.assertEquals(2, upstreamCacheManager.findUpstreamListBySelectorId(SELECTOR_ID).size());
+
+        repository().saveOrUpdateSelectorData(selector(handleOf("10.0.0.1:8189")));
+
+        List<Upstream> after = upstreamCacheManager.findUpstreamListBySelectorId(SELECTOR_ID);
+        Assertions.assertEquals(1, after.size(), "scaled-down upstream must be evicted from the cache");
+        Assertions.assertEquals("10.0.0.1:8189", after.get(0).getUrl());
+    }
+
+    @Test
+    public void testUnchangedSnapshotKeepsUpstreams() {
+        upstreamCacheManager.submit(SELECTOR_ID, List.of(upstream("10.0.0.1:8189")));
+
+        repository().saveOrUpdateSelectorData(selector(handleOf("10.0.0.1:8189")));
+
+        List<Upstream> after = upstreamCacheManager.findUpstreamListBySelectorId(SELECTOR_ID);
+        Assertions.assertEquals(1, after.size());
+        Assertions.assertEquals("10.0.0.1:8189", after.get(0).getUrl());
+    }
+
+    /**
+     * deleteSelectorWithRules must remove every rule and then the selector even though findRuleDataList exposes
+     * BaseDataCache's mutable internal list that each deleteRuleData call mutates — iterating over a copy is what
+     * keeps this from throwing ConcurrentModificationException halfway through the cleanup.
+     */
+    @Test
+    public void testDeleteSelectorWithRulesClearsRulesAndSelector() {
+        CommonPluginDataSubscriber pluginSubscriber = new CommonPluginDataSubscriber(
+                new ArrayList<>(), Mockito.mock(org.springframework.context.ApplicationEventPublisher.class),
+                new ShenyuConfig.SelectorMatchCache(), new ShenyuConfig.RuleMatchCache());
+        ShenyuCacheRepository repository = new ShenyuCacheRepository(pluginSubscriber,
+                Mockito.mock(CommonDiscoveryUpstreamDataSubscriber.class),
+                Mockito.mock(MetaDataSubscriber.class), Mockito.mock(MetaDataCacheSubscriber.class));
+        String selectorId = "gwapi-cascade-delete-test";
+        BaseDataCache.getInstance().cacheRuleData(RuleData.builder()
+                .id("rule-1").selectorId(selectorId).pluginName(PluginEnum.DIVIDE.getName()).sort(1).build());
+        BaseDataCache.getInstance().cacheRuleData(RuleData.builder()
+                .id("rule-2").selectorId(selectorId).pluginName(PluginEnum.DIVIDE.getName()).sort(2).build());
+
+        repository.deleteSelectorWithRules(PluginEnum.DIVIDE.getName(), selectorId);
+
+        Assertions.assertNull(BaseDataCache.getInstance().obtainRuleData(selectorId));
+    }
+
+    /**
+     * BaseDataCache.obtainRuleData returns null for a selector with no cached rules, and a first reconcile writes a
+     * rule before any rule of that selector is cached, so saveOrUpdateRuleData must tolerate the empty case instead
+     * of throwing NPE — that NPE aborted the Kubernetes reconciler loops and left the gateway without routing data.
+     */
+    @Test
+    public void testSaveOrUpdateRuleDataWithUncachedSelectorSubscribesRule() {
+        CommonPluginDataSubscriber pluginSubscriber = new CommonPluginDataSubscriber(
+                new ArrayList<>(), Mockito.mock(org.springframework.context.ApplicationEventPublisher.class),
+                new ShenyuConfig.SelectorMatchCache(), new ShenyuConfig.RuleMatchCache());
+        ShenyuCacheRepository repository = new ShenyuCacheRepository(pluginSubscriber,
+                Mockito.mock(CommonDiscoveryUpstreamDataSubscriber.class),
+                Mockito.mock(MetaDataSubscriber.class), Mockito.mock(MetaDataCacheSubscriber.class));
+        String selectorId = "gwapi-rule-first-write-test";
+
+        Assertions.assertDoesNotThrow(() -> repository.saveOrUpdateRuleData(RuleData.builder()
+                .id("rule-1").selectorId(selectorId).pluginName(PluginEnum.DIVIDE.getName()).sort(1).build()));
+
+        Assertions.assertEquals(1, repository.findRuleDataList(selectorId).size());
+        BaseDataCache.getInstance().removeRuleDataBySelectorId(selectorId);
+    }
+
+    /** Reconcilers iterate findRuleDataList directly (e.g. */
+    @Test
+    public void testFindRuleDataListForUnknownSelectorIsEmpty() {
+        List<RuleData> rules = repository().findRuleDataList("gwapi-no-such-selector");
+
+        Assertions.assertNotNull(rules);
+        Assertions.assertTrue(rules.isEmpty());
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifierTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifierTest.java
new file mode 100644
index 0000000000..77010898f0
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVerifierTest.java
@@ -0,0 +1,121 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import java.util.Objects;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonObject;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.ApiResponse;
+import okhttp3.Call;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+import java.util.Map;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * CRD verification: fail-fast on missing discovery entries, and bundle-version detection
+ * feeding the SupportedVersion condition (missing annotation means unsupported).
+ */
+public final class GatewayApiCrdVerifierTest {
+
+    @Test
+    public void testSupportedBundleVersionsDetected() throws Exception {
+        ApiClient apiClient = mockApiClient(discovery(true), crd("v1.5.1"));
+
+        GatewayApiCrdVersions versions = GatewayApiCrdVerifier.verify(apiClient);
+
+        Assertions.assertTrue(versions.isSupported());
+        Assertions.assertEquals(java.util.List.of("v1.5.1"), versions.detectedVersions());
+    }
+
+    @Test
+    public void testMissingBundleVersionAnnotationIsUnsupported() throws Exception {
+        ApiClient apiClient = mockApiClient(discovery(true), crd(null));
+
+        GatewayApiCrdVersions versions = GatewayApiCrdVerifier.verify(apiClient);
+
+        Assertions.assertFalse(versions.isSupported());
+        Assertions.assertTrue(versions.detectedVersions().isEmpty());
+    }
+
+    @Test
+    public void testMissingRequiredResourceFailsFast() throws Exception {
+        ApiClient apiClient = mockApiClient(discovery(false), crd("v1.5.1"));
+
+        Assertions.assertThrows(IllegalStateException.class, () -> GatewayApiCrdVerifier.verify(apiClient));
+    }
+
+    private JsonObject discovery(final boolean complete) {
+        JsonArray resources = new JsonArray();
+        resources.add(resource("gatewayclasses"));
+        resources.add(resource("gateways"));
+        resources.add(resource("httproutes"));
+        if (complete) {
+            resources.add(resource("referencegrants"));
+        }
+        JsonObject discovery = new JsonObject();
+        discovery.add("resources", resources);
+        return discovery;
+    }
+
+    private JsonObject resource(final String name) {
+        JsonObject resource = new JsonObject();
+        resource.addProperty("name", name);
+        resource.addProperty("verbs", "get");
+        return resource;
+    }
+
+    /** A CRD object carrying (or missing) the bundle-version annotation. */
+    private JsonObject crd(final String bundleVersion) {
+        JsonObject metadata = new JsonObject();
+        if (Objects.nonNull(bundleVersion)) {
+            JsonObject annotations = new JsonObject();
+            annotations.addProperty(GatewayApiCrdVerifier.BUNDLE_VERSION_ANNOTATION, bundleVersion);
+            metadata.add("annotations", annotations);
+        }
+        JsonObject crd = new JsonObject();
+        crd.add("metadata", metadata);
+        return crd;
+    }
+
+    /**
+     * The first API call is the discovery document; every later call returns the same CRD
+     * object for all four required CRDs.
+     */
+    @SuppressWarnings("unchecked")
+    private ApiClient mockApiClient(final JsonObject discovery, final JsonObject crd) throws Exception {
+        ApiClient apiClient = mock(ApiClient.class);
+        when(apiClient.getAuthentications()).thenReturn(Map.of());
+        when(apiClient.buildCall(any(), any(), any(), any(), any(), any(), any(), any(), any(), any()))
+                .thenReturn(mock(Call.class));
+
+        ApiResponse<JsonObject> discoveryResponse = mock(ApiResponse.class);
+        when(discoveryResponse.getData()).thenReturn(discovery);
+        ApiResponse<JsonObject> crdResponse = mock(ApiResponse.class);
+        when(crdResponse.getData()).thenReturn(crd);
+        when(apiClient.<JsonObject>execute(any(Call.class), eq(JsonObject.class)))
+                .thenReturn(discoveryResponse, crdResponse, crdResponse, crdResponse, crdResponse);
+        return apiClient;
+    }
+}
diff --git a/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersionsTest.java b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersionsTest.java
new file mode 100644
index 0000000000..cce4d4a7ad
--- /dev/null
+++ b/shenyu-kubernetes-controller/src/test/java/org/apache/shenyu/k8s/common/GatewayApiCrdVersionsTest.java
@@ -0,0 +1,52 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.k8s.common;
+
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+
+/**
+ * Bundle version parsing and the minimum-version verdict feeding the SupportedVersion
+ * condition.
+ */
+public final class GatewayApiCrdVersionsTest {
+
+    @Test
+    public void testMajorMinorParsing() {
+        Assertions.assertArrayEquals(new int[]{1, 5}, GatewayApiCrdVersions.majorMinor("v1.5.1"));
+        Assertions.assertArrayEquals(new int[]{1, 6}, GatewayApiCrdVersions.majorMinor("1.6.0-rc2"));
+        Assertions.assertNull(GatewayApiCrdVersions.majorMinor("main"));
+        Assertions.assertNull(GatewayApiCrdVersions.majorMinor(null));
+        Assertions.assertNull(GatewayApiCrdVersions.majorMinor("v1"));
+    }
+
+    @Test
+    public void testAtLeastMinSupported() {
+        Assertions.assertTrue(GatewayApiCrdVersions.atLeastMinSupported(new int[]{1, 5}));
+        Assertions.assertTrue(GatewayApiCrdVersions.atLeastMinSupported(new int[]{2, 0}));
+        Assertions.assertFalse(GatewayApiCrdVersions.atLeastMinSupported(new int[]{1, 4}));
+        Assertions.assertFalse(GatewayApiCrdVersions.atLeastMinSupported(null));
+    }
+
+    @Test
+    public void testDescribeDetected() {
+        Assertions.assertEquals("none annotated", new GatewayApiCrdVersions(java.util.List.of(), false).describeDetected());
+        Assertions.assertEquals("v1.5.1, v1.6.0",
+                new GatewayApiCrdVersions(java.util.List.of("v1.6.0", "v1.5.1"), true).describeDetected());
+    }
+}
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/pom.xml b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/pom.xml
index fd16891e11..1789cc837c 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/pom.xml
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/pom.xml
@@ -31,5 +31,10 @@
             <artifactId>shenyu-kubernetes-controller</artifactId>
             <version>${project.version}</version>
         </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-actuator</artifactId>
+            <optional>true</optional>
+        </dependency>
     </dependencies>
 </project>
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/ControllerManagerLifecycle.java b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/ControllerManagerLifecycle.java
new file mode 100644
index 0000000000..6842548d08
--- /dev/null
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/ControllerManagerLifecycle.java
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.springboot.starter.k8s;
+
+import io.kubernetes.client.extended.controller.ControllerManager;
+import org.springframework.context.SmartLifecycle;
+
+import java.util.List;
+import java.util.concurrent.ExecutorService;
+
+/**
+ * Starts and stops the {@link ControllerManager}s through the Spring lifecycle instead of
+ * submitting them as a side effect of bean creation, so controllers only start once the
+ * context is fully refreshed and shut down cleanly on close.
+ */
+final class ControllerManagerLifecycle implements SmartLifecycle {
+
+    private final List<ControllerManager> controllerManagers;
+
+    private final ExecutorService executorService;
+
+    private volatile boolean running;
+
+    ControllerManagerLifecycle(final List<ControllerManager> controllerManagers,
+                               final ExecutorService executorService) {
+        this.controllerManagers = controllerManagers;
+        this.executorService = executorService;
+    }
+
+    @Override
+    public void start() {
+        controllerManagers.forEach(executorService::submit);
+        running = true;
+    }
+
+    @Override
+    public void stop() {
+        controllerManagers.forEach(ControllerManager::shutdown);
+        running = false;
+    }
+
+    @Override
+    public boolean isRunning() {
+        return running;
+    }
+}
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/GatewayApiControllerConfiguration.java b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/GatewayApiControllerConfiguration.java
new file mode 100644
index 0000000000..fe899bc7fa
--- /dev/null
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/GatewayApiControllerConfiguration.java
@@ -0,0 +1,489 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.shenyu.springboot.starter.k8s;
+
+import io.kubernetes.client.extended.controller.Controller;
+import io.kubernetes.client.extended.controller.ControllerManager;
+import io.kubernetes.client.extended.controller.DefaultController;
+import io.kubernetes.client.extended.controller.builder.ControllerBuilder;
+import io.kubernetes.client.extended.controller.builder.DefaultControllerBuilder;
+import io.kubernetes.client.extended.controller.reconciler.Request;
+import io.kubernetes.client.extended.controller.reconciler.Reconciler;
+import io.kubernetes.client.extended.workqueue.DefaultRateLimitingQueue;
+import io.kubernetes.client.extended.workqueue.RateLimitingQueue;
+import io.kubernetes.client.extended.workqueue.WorkQueue;
+import io.kubernetes.client.informer.SharedIndexInformer;
+import io.kubernetes.client.informer.SharedInformerFactory;
+import io.kubernetes.client.informer.cache.Lister;
+import io.kubernetes.client.openapi.ApiClient;
+import io.kubernetes.client.openapi.models.V1Endpoints;
+import io.kubernetes.client.openapi.models.V1EndpointsList;
+import io.kubernetes.client.openapi.models.V1Service;
+import io.kubernetes.client.openapi.models.V1ServiceList;
+import io.kubernetes.client.util.generic.GenericKubernetesApi;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesApi;
+import io.kubernetes.client.util.generic.dynamic.DynamicKubernetesObject;
+import org.apache.shenyu.common.dto.PluginData;
+import org.apache.shenyu.common.enums.PluginEnum;
+import org.apache.shenyu.common.enums.PluginRoleEnum;
+import org.apache.shenyu.k8s.cache.K8sCacheReadiness;
+import org.apache.shenyu.k8s.common.GatewayApiConstants;
+import org.apache.shenyu.k8s.common.GatewayApiCrdVersions;
+import org.apache.shenyu.k8s.common.GatewayApiCrdVerifier;
+import org.apache.shenyu.k8s.parser.HttpRouteParser;
+import org.apache.shenyu.k8s.reconciler.GatewayClassReconciler;
+import org.apache.shenyu.k8s.reconciler.GatewayReconciler;
+import org.apache.shenyu.k8s.reconciler.HTTPRouteReconciler;
+import org.apache.shenyu.k8s.reconciler.HttpRouteEndpointsHandler;
+import org.apache.shenyu.k8s.reconciler.HttpRouteServiceHandler;
+import org.apache.shenyu.k8s.reconciler.ReferenceGrantReconciler;
+import org.apache.shenyu.k8s.repository.ShenyuCacheRepository;
+import org.apache.shenyu.plugin.base.cache.CommonDiscoveryUpstreamDataSubscriber;
+import org.apache.shenyu.plugin.base.cache.CommonPluginDataSubscriber;
+import org.apache.shenyu.plugin.global.subsciber.MetaDataCacheSubscriber;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.HealthIndicator;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
+import org.springframework.context.SmartLifecycle;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.context.annotation.DependsOn;
+import org.springframework.core.env.Environment;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+/**
+ * Spring Boot auto-configuration for the Kubernetes Gateway API controller mode: the
+ * bootstrap embeds the controller and writes parsed selector/rule config directly into the
+ * in-process {@code BaseDataCache}. Multiple replicas need no leader election —
+ * reconciliation is idempotent — but deployments MUST gate readiness on
+ * {@code k8sCacheReadiness} so a cold pod receives no traffic before its informers sync.
+ */
+@Configuration
+@ConditionalOnProperty(name = "shenyu.k8s.mode", havingValue = "gateway-api")
+public class GatewayApiControllerConfiguration {
+
+    /** Resync re-drives watch gaps (e.g. a grant added after ResolvedRefs=False); withResyncPeriod is a no-op in client-java. */
+    private static final long RESYNC_PERIOD_MILLIS = Duration.ofMinutes(1).toMillis();
+
+    private static final int DEFAULT_SERVER_PORT = 9195;
+
+    /**
+     * One factory per resource type: DynamicKubernetesObject class keys collide in a shared factory.
+     *
+     * @param apiClient the Kubernetes API client
+     * @return the GatewayClass SharedInformerFactory
+     */
+    @Bean("gatewayclass-shared-informer-factory")
+    public SharedInformerFactory gatewayClassSharedInformerFactory(final ApiClient apiClient) {
+        SharedInformerFactory factory = new SharedInformerFactory(apiClient);
+        DynamicKubernetesApi gatewayClassApi = new DynamicKubernetesApi(
+                GatewayApiConstants.GATEWAY_API_GROUP,
+                GatewayApiConstants.GATEWAY_API_VERSION,
+                "gatewayclasses",
+                apiClient);
+        factory.sharedIndexInformerFor(gatewayClassApi, DynamicKubernetesObject.class, RESYNC_PERIOD_MILLIS);
+        return factory;
+    }
+
+    @Bean("gateway-shared-informer-factory")
+    public SharedInformerFactory gatewaySharedInformerFactory(final ApiClient apiClient) {
+        SharedInformerFactory factory = new SharedInformerFactory(apiClient);
+        DynamicKubernetesApi gatewayApi = new DynamicKubernetesApi(
+                GatewayApiConstants.GATEWAY_API_GROUP,
+                GatewayApiConstants.GATEWAY_API_VERSION,
+                "gateways",
+                apiClient);
+        factory.sharedIndexInformerFor(gatewayApi, DynamicKubernetesObject.class, RESYNC_PERIOD_MILLIS);
+        return factory;
+    }
+
+    /**
+     * HTTPRoute, Service and Endpoints informers; Services map backendRef ports to named targetPorts.
+     *
+     * @param apiClient the Kubernetes API client
+     * @return the HTTPRoute, Service and Endpoints SharedInformerFactory
+     */
+    @Bean("httproute-shared-informer-factory")
+    public SharedInformerFactory httpRouteSharedInformerFactory(final ApiClient apiClient) {
+        SharedInformerFactory factory = new SharedInformerFactory(apiClient);
+        DynamicKubernetesApi httpRouteApi = new DynamicKubernetesApi(
+                GatewayApiConstants.GATEWAY_API_GROUP,
+                GatewayApiConstants.GATEWAY_API_VERSION,
+                "httproutes",
+                apiClient);
+        factory.sharedIndexInformerFor(httpRouteApi, DynamicKubernetesObject.class, RESYNC_PERIOD_MILLIS);
+
+        GenericKubernetesApi<V1Service, V1ServiceList> serviceApi = new GenericKubernetesApi<>(V1Service.class,
+                V1ServiceList.class, "", "v1", "services", apiClient);
+        factory.sharedIndexInformerFor(serviceApi, V1Service.class, RESYNC_PERIOD_MILLIS);
+
+        GenericKubernetesApi<V1Endpoints, V1EndpointsList> endpointsApi = new GenericKubernetesApi<>(V1Endpoints.class,
+                V1EndpointsList.class, "", "v1", "endpoints", apiClient);
+        factory.sharedIndexInformerFor(endpointsApi, V1Endpoints.class, RESYNC_PERIOD_MILLIS);
+        return factory;
+    }
+
+    @Bean("referencegrant-shared-informer-factory")
+    public SharedInformerFactory referenceGrantSharedInformerFactory(final ApiClient apiClient) {
+        SharedInformerFactory factory = new SharedInformerFactory(apiClient);
+        DynamicKubernetesApi referenceGrantApi = new DynamicKubernetesApi(
+                GatewayApiConstants.GATEWAY_API_GROUP,
+                GatewayApiConstants.GATEWAY_API_VERSION,
+                "referencegrants",
+                apiClient);
+        factory.sharedIndexInformerFor(referenceGrantApi, DynamicKubernetesObject.class, 0);
+        return factory;
+    }
+
+    @Bean(destroyMethod = "shutdown")
+    public ExecutorService controllerExecutorService() {
+        return Executors.newCachedThreadPool(r -> {
+            Thread t = new Thread(r, "shenyu-k8s-controller");
+            t.setDaemon(true);
+            return t;
+        });
+    }
+
+    @Bean("gatewayclass-controller-manager")
+    public ControllerManager gatewayClassControllerManager(
+            @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+            @Qualifier("gatewayclass-controller") final Controller gatewayClassController) {
+        return new ControllerManager(gatewayClassFactory, gatewayClassController);
+    }
+
+    @Bean("gateway-controller-manager")
+    public ControllerManager gatewayControllerManager(
+            @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+            @Qualifier("gateway-controller") final Controller gatewayController) {
+        return new ControllerManager(gatewayFactory, gatewayController);
+    }
+
+    @Bean("httproute-controller-manager")
+    @DependsOn({"httpRouteEndpointsHandler", "httpRouteServiceHandler"})
+    public ControllerManager httpRouteControllerManager(
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            @Qualifier("httproute-controller") final Controller httpRouteController) {
+        return new ControllerManager(httpRouteFactory, httpRouteController);
+    }
+
+    /**
+     * Re-queues HTTPRoutes referencing the grant's namespace so a revoked grant stops traffic immediately.
+     *
+     * @param referenceGrantFactory the ReferenceGrant SharedInformerFactory
+     * @param httpRouteFactory the HTTPRoute SharedInformerFactory
+     * @param httpRouteWorkQueue the HTTPRoute controller work queue
+     * @return the ReferenceGrant controller
+     */
+    @Bean("referencegrant-controller")
+    public Controller referenceGrantController(
+            @Qualifier("referencegrant-shared-informer-factory") final SharedInformerFactory referenceGrantFactory,
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(referenceGrantFactory);
+        builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q)
+                .build());
+        builder.withWorkerCount(1);
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        Reconciler reconciler = new ReferenceGrantReconciler(httpRouteInformer, httpRouteWorkQueue);
+        return builder.withReconciler(reconciler).withName("referenceGrantController").build();
+    }
+
+    @Bean("referencegrant-controller-manager")
+    public ControllerManager referenceGrantControllerManager(
+            @Qualifier("referencegrant-shared-informer-factory") final SharedInformerFactory referenceGrantFactory,
+            @Qualifier("referencegrant-controller") final Controller referenceGrantController) {
+        return new ControllerManager(referenceGrantFactory, referenceGrantController);
+    }
+
+    /**
+     * Fails fast when required CRDs are missing; the result feeds the SupportedVersion condition.
+     *
+     * @param apiClient the Kubernetes API client
+     * @return the detected CRD bundle versions
+     */
+    @Bean
+    public GatewayApiCrdVersions gatewayApiCrdVersions(final ApiClient apiClient) {
+        return GatewayApiCrdVerifier.verify(apiClient);
+    }
+
+    @Bean
+    public SmartLifecycle k8sControllerLifecycle(final List<ControllerManager> controllerManagers,
+                                                 final ExecutorService controllerExecutorService) {
+        return new ControllerManagerLifecycle(controllerManagers, controllerExecutorService);
+    }
+
+    @Bean("gatewayclass-controller")
+    public Controller gatewayClassController(
+            @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+            @Qualifier("gatewayclass-work-queue") final RateLimitingQueue<Request> gatewayClassWorkQueue,
+            final GatewayClassReconciler gatewayClassReconciler) {
+        DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(gatewayClassFactory)
+                .withWorkQueue(gatewayClassWorkQueue);
+        builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q)
+                .build());
+        builder.withWorkerCount(1);
+        return builder.withReconciler(gatewayClassReconciler).withName("gatewayClassController").build();
+    }
+
+    @Bean("gateway-controller")
+    public Controller gatewayController(
+            @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+            final GatewayReconciler gatewayReconciler) {
+        DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(gatewayFactory);
+        builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q)
+                .build());
+        builder.withWorkerCount(2);
+        return builder.withReconciler(gatewayReconciler).withName("gatewayController").build();
+    }
+
+    /**
+     * Also fed by the Gateway reconciler on accept/delete so finalizer updates are immediate.
+     *
+     * @param controllerExecutorService the shared controller executor service
+     * @return the GatewayClass work queue
+     */
+    @Bean("gatewayclass-work-queue")
+    public RateLimitingQueue<Request> gatewayClassWorkQueue(final ExecutorService controllerExecutorService) {
+        return new DefaultRateLimitingQueue<>(controllerExecutorService);
+    }
+
+    @Bean("httproute-work-queue")
+    public RateLimitingQueue<Request> httpRouteWorkQueue(final ExecutorService controllerExecutorService) {
+        return new DefaultRateLimitingQueue<>(controllerExecutorService);
+    }
+
+    @Bean("httproute-controller")
+    public Controller httpRouteController(
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            final HTTPRouteReconciler httpRouteReconciler,
+            @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        DefaultControllerBuilder builder = ControllerBuilder.defaultBuilder(httpRouteFactory)
+                .withWorkQueue(httpRouteWorkQueue);
+        builder = builder.watch(q -> ControllerBuilder.controllerWatchBuilder(DynamicKubernetesObject.class, q)
+                .build());
+        builder.withWorkerCount(2);
+        return builder.withReconciler(httpRouteReconciler).withName("httpRouteController").build();
+    }
+
+    /**
+     * Enqueues routes whose backend Service Endpoints changed; a manager dependency so indexers register first.
+     *
+     * @param httpRouteFactory the HTTPRoute, Service and Endpoints SharedInformerFactory
+     * @param httpRouteWorkQueue the HTTPRoute controller work queue
+     * @return the registered Endpoints event handler
+     */
+    @Bean
+    public HttpRouteEndpointsHandler httpRouteEndpointsHandler(
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        SharedIndexInformer<V1Endpoints> endpointsInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(V1Endpoints.class);
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        HttpRouteEndpointsHandler handler = new HttpRouteEndpointsHandler(httpRouteInformer, httpRouteWorkQueue);
+        endpointsInformer.addEventHandler(handler);
+        return handler;
+    }
+
+    /**
+     * Service port/targetPort edits do not touch Endpoints, so they need their own trigger.
+     *
+     * @param httpRouteFactory the HTTPRoute, Service and Endpoints SharedInformerFactory
+     * @param httpRouteWorkQueue the HTTPRoute controller work queue
+     * @return the registered Service event handler
+     */
+    @Bean
+    public HttpRouteServiceHandler httpRouteServiceHandler(
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        SharedIndexInformer<V1Service> serviceInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(V1Service.class);
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        HttpRouteServiceHandler handler = new HttpRouteServiceHandler(httpRouteInformer, httpRouteWorkQueue);
+        serviceInformer.addEventHandler(handler);
+        return handler;
+    }
+
+    @Bean
+    public GatewayClassReconciler gatewayClassReconciler(
+            @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+            @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+            @Qualifier("gateway-controller") final Controller gatewayController,
+            final ApiClient apiClient,
+            final GatewayApiCrdVersions gatewayApiCrdVersions) {
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer =
+                gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer =
+                gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        RateLimitingQueue<Request> gatewayWorkQueue = ((DefaultController) gatewayController).getWorkQueue();
+        return new GatewayClassReconciler(gatewayClassInformer, gatewayInformer, gatewayWorkQueue, apiClient, gatewayApiCrdVersions);
+    }
+
+    @Bean
+    public GatewayReconciler gatewayReconciler(
+            @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+            @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            @Qualifier("httproute-controller") final Controller httpRouteController,
+            @Qualifier("gatewayclass-work-queue") final RateLimitingQueue<Request> gatewayClassWorkQueue,
+            final ShenyuCacheRepository shenyuCacheRepository,
+            final ApiClient apiClient,
+            final Environment environment) {
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer =
+                gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer =
+                gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        RateLimitingQueue<Request> httpRouteWorkQueue = ((DefaultController) httpRouteController).getWorkQueue();
+        int servedPort = environment.getProperty("server.port", Integer.class, DEFAULT_SERVER_PORT);
+        return new GatewayReconciler(gatewayInformer, gatewayClassInformer, httpRouteInformer,
+                shenyuCacheRepository, httpRouteWorkQueue, gatewayClassWorkQueue, apiClient, servedPort);
+    }
+
+    @Bean
+    public HTTPRouteReconciler httpRouteReconciler(
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+            @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+            final HttpRouteParser httpRouteParser,
+            final ShenyuCacheRepository shenyuCacheRepository,
+            final ApiClient apiClient,
+            final Environment environment) {
+        SharedIndexInformer<DynamicKubernetesObject> httpRouteInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayInformer =
+                gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        SharedIndexInformer<DynamicKubernetesObject> gatewayClassInformer =
+                gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        int servedPort = environment.getProperty("server.port", Integer.class, DEFAULT_SERVER_PORT);
+        return new HTTPRouteReconciler(httpRouteInformer, gatewayInformer, gatewayClassInformer,
+                httpRouteParser, shenyuCacheRepository, apiClient, servedPort);
+    }
+
+    @Bean
+    public HttpRouteParser httpRouteParser(
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            @Qualifier("referencegrant-shared-informer-factory") final SharedInformerFactory referenceGrantFactory) {
+        SharedIndexInformer<V1Service> serviceInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(V1Service.class);
+        SharedIndexInformer<V1Endpoints> endpointsInformer =
+                httpRouteFactory.getExistingSharedIndexInformer(V1Endpoints.class);
+        SharedIndexInformer<DynamicKubernetesObject> referenceGrantInformer =
+                referenceGrantFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class);
+        Lister<V1Service> serviceLister = new Lister<>(serviceInformer.getIndexer());
+        Lister<V1Endpoints> endpointsLister = new Lister<>(endpointsInformer.getIndexer());
+        Lister<DynamicKubernetesObject> referenceGrantLister = new Lister<>(referenceGrantInformer.getIndexer());
+        return new HttpRouteParser(endpointsLister, serviceLister, referenceGrantLister);
+    }
+
+    @Bean
+    public ShenyuCacheRepository shenyuCacheRepository(final CommonPluginDataSubscriber pluginDataSubscriber,
+                                                       final CommonDiscoveryUpstreamDataSubscriber discoveryUpstreamDataSubscriber,
+                                                       final MetaDataCacheSubscriber metaDataSubscriber) {
+        ShenyuCacheRepository repository = new ShenyuCacheRepository(pluginDataSubscriber, discoveryUpstreamDataSubscriber,
+                metaDataSubscriber, metaDataSubscriber);
+        enablePlugin(repository, PluginEnum.GLOBAL, null);
+        enablePlugin(repository, PluginEnum.URI, null);
+        enablePlugin(repository, PluginEnum.NETTY_HTTP_CLIENT, null);
+        enablePlugin(repository, PluginEnum.DIVIDE, "{multiSelectorHandle: 1, multiRuleHandle:0}");
+        return repository;
+    }
+
+    /**
+     * Readiness needs informer sync AND drained work queues; the grant queue matters because its reconcile re-queues routes.
+     *
+     * @param gatewayClassFactory the GatewayClass SharedInformerFactory
+     * @param gatewayFactory the Gateway SharedInformerFactory
+     * @param httpRouteFactory the HTTPRoute, Service and Endpoints SharedInformerFactory
+     * @param referenceGrantFactory the ReferenceGrant SharedInformerFactory
+     * @param gatewayClassController the GatewayClass controller (for its work queue)
+     * @param gatewayController the Gateway controller (for its work queue)
+     * @param referenceGrantController the ReferenceGrant controller (for its work queue)
+     * @param httpRouteWorkQueue the HTTPRoute controller work queue
+     * @return readiness aggregator over all registered informers and work queues
+     */
+    @Bean
+    public K8sCacheReadiness k8sCacheReadiness(
+            @Qualifier("gatewayclass-shared-informer-factory") final SharedInformerFactory gatewayClassFactory,
+            @Qualifier("gateway-shared-informer-factory") final SharedInformerFactory gatewayFactory,
+            @Qualifier("httproute-shared-informer-factory") final SharedInformerFactory httpRouteFactory,
+            @Qualifier("referencegrant-shared-informer-factory") final SharedInformerFactory referenceGrantFactory,
+            @Qualifier("gatewayclass-controller") final Controller gatewayClassController,
+            @Qualifier("gateway-controller") final Controller gatewayController,
+            @Qualifier("referencegrant-controller") final Controller referenceGrantController,
+            @Qualifier("httproute-work-queue") final RateLimitingQueue<Request> httpRouteWorkQueue) {
+        List<SharedIndexInformer<?>> informers = new ArrayList<>();
+        informers.add(gatewayClassFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class));
+        informers.add(gatewayFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class));
+        informers.add(httpRouteFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class));
+        informers.add(httpRouteFactory.getExistingSharedIndexInformer(V1Service.class));
+        informers.add(httpRouteFactory.getExistingSharedIndexInformer(V1Endpoints.class));
+        informers.add(referenceGrantFactory.getExistingSharedIndexInformer(DynamicKubernetesObject.class));
+        if (informers.stream().anyMatch(Objects::isNull)) {
+            throw new IllegalStateException("Expected informer not registered; informer factory wiring is inconsistent");
+        }
+        List<WorkQueue<?>> workQueues = List.of(
+                ((DefaultController) gatewayClassController).getWorkQueue(),
+                ((DefaultController) gatewayController).getWorkQueue(),
+                ((DefaultController) referenceGrantController).getWorkQueue(),
+                httpRouteWorkQueue);
+        return new K8sCacheReadiness(informers, workQueues);
+    }
+
+    private void enablePlugin(final ShenyuCacheRepository shenyuCacheRepository, final PluginEnum pluginEnum, final String config) {
+        PluginData pluginData = PluginData.builder()
+                .id(String.valueOf(pluginEnum.getCode()))
+                .name(pluginEnum.getName())
+                .config(config)
+                .role(PluginRoleEnum.SYS.getName())
+                .enabled(true)
+                .sort(pluginEnum.getCode())
+                .build();
+        shenyuCacheRepository.saveOrUpdatePluginData(pluginData);
+    }
+
+    /**
+     * Isolated from the CGLIB-proxied outer class (resolving an actuator @Bean without
+     * actuator fails hard); repeats the mode condition because a nested @Configuration is
+     * an independent scan candidate.
+     */
+    @Configuration
+    @ConditionalOnProperty(name = "shenyu.k8s.mode", havingValue = "gateway-api")
+    @ConditionalOnClass(name = "org.springframework.boot.actuate.health.HealthIndicator")
+    static class HealthIndicatorConfiguration {
+
+        @Bean
+        public HealthIndicator k8sCacheReadinessHealthIndicator(final K8sCacheReadiness k8sCacheReadiness) {
+            return () -> k8sCacheReadiness.isReady()
+                    ? Health.up().withDetail("pendingInformers", 0L).withDetail("pendingWorkItems", 0L).build()
+                    : Health.down().withDetail("pendingInformers", k8sCacheReadiness.pendingInformers())
+                            .withDetail("pendingWorkItems", k8sCacheReadiness.pendingWorkItems()).build();
+        }
+    }
+}
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/IngressControllerConfiguration.java b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/IngressControllerConfiguration.java
index 0a6ad99abd..fe16e76d8b 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/IngressControllerConfiguration.java
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/java/org/apache/shenyu/springboot/starter/k8s/IngressControllerConfiguration.java
@@ -66,6 +66,7 @@ import java.util.concurrent.Executors;
  * The type shenyu ingress controller configuration.
  */
 @Configuration
+@ConditionalOnProperty(name = "shenyu.k8s.mode", havingValue = "ingress", matchIfMissing = true)
 public class IngressControllerConfiguration {
 
     /**
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring.factories b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring.factories
index 6899440f01..a386224c21 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring.factories
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring.factories
@@ -1,3 +1,4 @@
 # Auto Configure
 org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
-org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration,\
+org.apache.shenyu.springboot.starter.k8s.GatewayApiControllerConfiguration
diff --git a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
index 3eb3d33916..b641d56b70 100644
--- a/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
+++ b/shenyu-spring-boot-starter/shenyu-spring-boot-starter-k8s/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
@@ -16,3 +16,4 @@
 #
 
 org.apache.shenyu.springboot.starter.k8s.IngressControllerConfiguration
+org.apache.shenyu.springboot.starter.k8s.GatewayApiControllerConfiguration