diff --git a/app/models/namespaces/traversal/linear.rb b/app/models/namespaces/traversal/linear.rb
index 3d78f3846348e8f5b3d35a4734280155e1df2d37..79df466fd641bd38dac1d15def87f5ad7d319375 100644
--- a/app/models/namespaces/traversal/linear.rb
+++ b/app/models/namespaces/traversal/linear.rb
@@ -37,6 +37,7 @@ module Namespaces
   module Traversal
     module Linear
       extend ActiveSupport::Concern
+      include LinearScopes
 
       UnboundedSearch = Class.new(StandardError)
 
@@ -44,14 +45,6 @@ module Linear
         before_update :lock_both_roots, if: -> { sync_traversal_ids? && parent_id_changed? }
         after_create :sync_traversal_ids, if: -> { sync_traversal_ids? }
         after_update :sync_traversal_ids, if: -> { sync_traversal_ids? && saved_change_to_parent_id? }
-
-        scope :traversal_ids_contains, ->(ids) { where("traversal_ids @> (?)", ids) }
-        # When filtering namespaces by the traversal_ids column to compile a
-        # list of namespace IDs, it's much faster to reference the ID in
-        # traversal_ids than the primary key ID column.
-        # WARNING This scope must be used behind a linear query feature flag
-        # such as `use_traversal_ids`.
-        scope :as_ids, -> { select('traversal_ids[array_length(traversal_ids, 1)] AS id') }
       end
 
       def sync_traversal_ids?
@@ -164,20 +157,14 @@ def lock_both_roots
         Namespace.lock.select(:id).where(id: roots).order(id: :asc).load
       end
 
-      # Make sure we drop the STI `type = 'Group'` condition for better performance.
-      # Logically equivalent so long as hierarchies remain homogeneous.
-      def without_sti_condition
-        self.class.unscope(where: :type)
-      end
-
       # Search this namespace's lineage. Bound inclusively by top node.
       def lineage(top: nil, bottom: nil, hierarchy_order: nil)
         raise UnboundedSearch, 'Must bound search by either top or bottom' unless top || bottom
 
-        skope = without_sti_condition
+        skope = self.class.without_sti_condition
 
         if top
-          skope = skope.traversal_ids_contains("{#{top.id}}")
+          skope = skope.where("traversal_ids @> ('{?}')", top.id)
         end
 
         if bottom
diff --git a/app/models/namespaces/traversal/linear_scopes.rb b/app/models/namespaces/traversal/linear_scopes.rb
new file mode 100644
index 0000000000000000000000000000000000000000..f352497e6decadfc04f641d024906565927289ff
--- /dev/null
+++ b/app/models/namespaces/traversal/linear_scopes.rb
@@ -0,0 +1,59 @@
+# frozen_string_literal: true
+
+module Namespaces
+  module Traversal
+    module LinearScopes
+      extend ActiveSupport::Concern
+
+      class_methods do
+        # When filtering namespaces by the traversal_ids column to compile a
+        # list of namespace IDs, it can be faster to reference the ID in
+        # traversal_ids than the primary key ID column.
+        def as_ids
+          return super unless use_traversal_ids?
+
+          select('namespaces.traversal_ids[array_length(namespaces.traversal_ids, 1)] AS id')
+        end
+
+        def self_and_descendants
+          return super unless use_traversal_ids?
+
+          without_dups = self_and_descendants_with_duplicates
+            .select('DISTINCT on(namespaces.id) namespaces.*')
+
+          # Wrap the `SELECT DISTINCT on(....)` with a normal query so we
+          # retain expected Rails behavior. Otherwise count and other
+          # aggregates won't work.
+          unscoped.without_sti_condition.from(without_dups, :namespaces)
+        end
+
+        def self_and_descendant_ids
+          return super unless use_traversal_ids?
+
+          self_and_descendants_with_duplicates.select('DISTINCT namespaces.id')
+        end
+
+        # Make sure we drop the STI `type = 'Group'` condition for better performance.
+        # Logically equivalent so long as hierarchies remain homogeneous.
+        def without_sti_condition
+          unscope(where: :type)
+        end
+
+        private
+
+        def use_traversal_ids?
+          Feature.enabled?(:use_traversal_ids, default_enabled: :yaml)
+        end
+
+        def self_and_descendants_with_duplicates
+          base_ids = select(:id)
+
+          unscoped
+            .without_sti_condition
+            .from("namespaces, (#{base_ids.to_sql}) base")
+            .where('namespaces.traversal_ids @> ARRAY[base.id]')
+        end
+      end
+    end
+  end
+end
diff --git a/app/models/namespaces/traversal/recursive.rb b/app/models/namespaces/traversal/recursive.rb
index d9e8743aa50a3748660892cc5de6bd382150ebe4..c1ada715d6dcb4f674d15ce2511d1743093bd50a 100644
--- a/app/models/namespaces/traversal/recursive.rb
+++ b/app/models/namespaces/traversal/recursive.rb
@@ -4,6 +4,7 @@ module Namespaces
   module Traversal
     module Recursive
       extend ActiveSupport::Concern
+      include RecursiveScopes
 
       def root_ancestor
         return self if parent.nil?
diff --git a/app/models/namespaces/traversal/recursive_scopes.rb b/app/models/namespaces/traversal/recursive_scopes.rb
new file mode 100644
index 0000000000000000000000000000000000000000..0dcb23b6567430e0a2f22f0ca52e3bab20dd4c5a
--- /dev/null
+++ b/app/models/namespaces/traversal/recursive_scopes.rb
@@ -0,0 +1,25 @@
+# frozen_string_literal: true
+
+module Namespaces
+  module Traversal
+    module RecursiveScopes
+      extend ActiveSupport::Concern
+
+      class_methods do
+        def as_ids
+          select('id')
+        end
+
+        def self_and_descendants
+          Gitlab::ObjectHierarchy.new(all).base_and_descendants
+        end
+        alias_method :recursive_self_and_descendants, :self_and_descendants
+
+        def self_and_descendant_ids
+          self_and_descendants.as_ids
+        end
+        alias_method :recursive_self_and_descendant_ids, :self_and_descendant_ids
+      end
+    end
+  end
+end
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index 57882b6f1b3a1f7fedd11634bf37cae11c233cef..3f626fb3fceae8206ffa8ebb64b2a56f4173557c 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -207,9 +207,23 @@
     it { is_expected.to include_module(Gitlab::VisibilityLevel) }
     it { is_expected.to include_module(Namespaces::Traversal::Recursive) }
     it { is_expected.to include_module(Namespaces::Traversal::Linear) }
+    it { is_expected.to include_module(Namespaces::Traversal::RecursiveScopes) }
+    it { is_expected.to include_module(Namespaces::Traversal::LinearScopes) }
   end
 
-  it_behaves_like 'linear namespace traversal'
+  context 'traversal scopes' do
+    context 'recursive' do
+      before do
+        stub_feature_flags(use_traversal_ids: false)
+      end
+
+      it_behaves_like 'namespace traversal scopes'
+    end
+
+    context 'linear' do
+      it_behaves_like 'namespace traversal scopes'
+    end
+  end
 
   context 'traversal_ids on create' do
     context 'default traversal_ids' do
diff --git a/spec/support/shared_examples/namespaces/linear_traversal_examples.rb b/spec/support/shared_examples/namespaces/linear_traversal_examples.rb
deleted file mode 100644
index 2fd90c36953b1bd29184f3037643d59c284ad917..0000000000000000000000000000000000000000
--- a/spec/support/shared_examples/namespaces/linear_traversal_examples.rb
+++ /dev/null
@@ -1,23 +0,0 @@
-# frozen_string_literal: true
-
-# Traversal examples common to linear and recursive methods are in
-# spec/support/shared_examples/namespaces/traversal_examples.rb
-
-RSpec.shared_examples 'linear namespace traversal' do
-  context 'when use_traversal_ids feature flag is enabled' do
-    before do
-      stub_feature_flags(use_traversal_ids: true)
-    end
-
-    context 'scopes' do
-      describe '.as_ids' do
-        let_it_be(:namespace1) { create(:group) }
-        let_it_be(:namespace2) { create(:group) }
-
-        subject { Namespace.where(id: [namespace1, namespace2]).as_ids.pluck(:id) }
-
-        it { is_expected.to contain_exactly(namespace1.id, namespace2.id) }
-      end
-    end
-  end
-end
diff --git a/spec/support/shared_examples/namespaces/traversal_scope_examples.rb b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb
new file mode 100644
index 0000000000000000000000000000000000000000..bc17d104034fc9966d4bec43ff93a51581848d66
--- /dev/null
+++ b/spec/support/shared_examples/namespaces/traversal_scope_examples.rb
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+RSpec.shared_examples 'namespace traversal scopes' do
+  # Hierarchy 1
+  let_it_be(:group_1) { create(:group) }
+  let_it_be(:nested_group_1) { create(:group, parent: group_1) }
+  let_it_be(:deep_nested_group_1) { create(:group, parent: nested_group_1) }
+
+  # Hierarchy 2
+  let_it_be(:group_2) { create(:group) }
+  let_it_be(:nested_group_2) { create(:group, parent: group_2) }
+  let_it_be(:deep_nested_group_2) { create(:group, parent: nested_group_2) }
+
+  # All groups
+  let_it_be(:groups) do
+    [
+      group_1, nested_group_1, deep_nested_group_1,
+      group_2, nested_group_2, deep_nested_group_2
+    ]
+  end
+
+  describe '.as_ids' do
+    subject { described_class.where(id: [group_1, group_2]).as_ids.pluck(:id) }
+
+    it { is_expected.to contain_exactly(group_1.id, group_2.id) }
+  end
+
+  describe '.without_sti_condition' do
+    subject { described_class.without_sti_condition }
+
+    it { expect(subject.where_values_hash).not_to have_key(:type) }
+  end
+
+  describe '.self_and_descendants' do
+    subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_descendants }
+
+    it { is_expected.to contain_exactly(nested_group_1, deep_nested_group_1, nested_group_2, deep_nested_group_2) }
+
+    context 'with duplicate descendants' do
+      subject { described_class.where(id: [group_1, group_2, nested_group_1]).self_and_descendants }
+
+      it { is_expected.to match_array(groups) }
+    end
+  end
+
+  describe '.self_and_descendant_ids' do
+    subject { described_class.where(id: [nested_group_1, nested_group_2]).self_and_descendant_ids.pluck(:id) }
+
+    it { is_expected.to contain_exactly(nested_group_1.id, deep_nested_group_1.id, nested_group_2.id, deep_nested_group_2.id) }
+  end
+end