diff --git a/src/Umbraco.Core/Services/ContentQueryOperationService.cs b/src/Umbraco.Core/Services/ContentQueryOperationService.cs
new file mode 100644
index 0000000000..6b66e65121
--- /dev/null
+++ b/src/Umbraco.Core/Services/ContentQueryOperationService.cs
@@ -0,0 +1,169 @@
+using Microsoft.Extensions.Logging;
+using Umbraco.Cms.Core.Events;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Persistence.Querying;
+using Umbraco.Cms.Core.Persistence.Repositories;
+using Umbraco.Cms.Core.Scoping;
+
+namespace Umbraco.Cms.Core.Services;
+
+///
+/// Implements content query operations (counting, filtering by type/level).
+///
+public class ContentQueryOperationService : ContentServiceBase, IContentQueryOperationService
+{
+ ///
+ /// Default ordering for paged queries.
+ ///
+ private static readonly Ordering DefaultSortOrdering = Ordering.By("sortOrder");
+
+ ///
+ /// Logger for this service (for debugging, performance monitoring, or error tracking).
+ ///
+ private readonly ILogger _logger;
+
+ public ContentQueryOperationService(
+ ICoreScopeProvider provider,
+ ILoggerFactory loggerFactory,
+ IEventMessagesFactory eventMessagesFactory,
+ IDocumentRepository documentRepository,
+ IAuditService auditService,
+ IUserIdKeyResolver userIdKeyResolver)
+ : base(provider, loggerFactory, eventMessagesFactory, documentRepository, auditService, userIdKeyResolver)
+ {
+ _logger = loggerFactory.CreateLogger();
+ }
+
+ #region Count Operations
+
+ ///
+ public int Count(string? contentTypeAlias = null)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.Count(contentTypeAlias);
+ }
+
+ ///
+ public int CountPublished(string? contentTypeAlias = null)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.CountPublished(contentTypeAlias);
+ }
+
+ ///
+ public int CountChildren(int parentId, string? contentTypeAlias = null)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.CountChildren(parentId, contentTypeAlias);
+ }
+
+ ///
+ public int CountDescendants(int parentId, string? contentTypeAlias = null)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ return DocumentRepository.CountDescendants(parentId, contentTypeAlias);
+ }
+
+ #endregion
+
+ #region Hierarchy Queries
+
+ ///
+ ///
+ /// The returned enumerable may be lazily evaluated. Callers should materialize
+ /// results (e.g., call ToList()) if they need to access them after the scope is disposed.
+ /// This is consistent with the existing ContentService.GetByLevel implementation.
+ ///
+ public IEnumerable GetByLevel(int level)
+ {
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+ IQuery? query = Query().Where(x => x.Level == level && x.Trashed == false);
+ return DocumentRepository.Get(query);
+ }
+
+ #endregion
+
+ #region Paged Type Queries
+
+ ///
+ public IEnumerable GetPagedOfType(
+ int contentTypeId,
+ long pageIndex,
+ int pageSize,
+ out long totalRecords,
+ IQuery? filter = null,
+ Ordering? ordering = null)
+ {
+ if (pageIndex < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(pageIndex));
+ }
+
+ if (pageSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(pageSize));
+ }
+
+ ordering ??= DefaultSortOrdering;
+
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+ scope.ReadLock(Constants.Locks.ContentTree);
+
+ // Note: filter=null is valid and means no additional filtering beyond the content type
+ return DocumentRepository.GetPage(
+ Query()?.Where(x => x.ContentTypeId == contentTypeId),
+ pageIndex,
+ pageSize,
+ out totalRecords,
+ filter,
+ ordering);
+ }
+
+ ///
+ public IEnumerable GetPagedOfTypes(
+ int[] contentTypeIds,
+ long pageIndex,
+ int pageSize,
+ out long totalRecords,
+ IQuery? filter = null,
+ Ordering? ordering = null)
+ {
+ ArgumentNullException.ThrowIfNull(contentTypeIds);
+
+ if (pageIndex < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(pageIndex));
+ }
+
+ if (pageSize <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(pageSize));
+ }
+
+ ordering ??= DefaultSortOrdering;
+
+ using ICoreScope scope = ScopeProvider.CreateCoreScope(autoComplete: true);
+
+ // Expression trees require a List for Contains() - array not supported.
+ // This O(n) copy is unavoidable but contentTypeIds is typically small.
+ List contentTypeIdsAsList = [.. contentTypeIds];
+
+ scope.ReadLock(Constants.Locks.ContentTree);
+
+ // Note: filter=null is valid and means no additional filtering beyond the content types
+ return DocumentRepository.GetPage(
+ Query()?.Where(x => contentTypeIdsAsList.Contains(x.ContentTypeId)),
+ pageIndex,
+ pageSize,
+ out totalRecords,
+ filter,
+ ordering);
+ }
+
+ #endregion
+}
diff --git a/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentQueryOperationServiceTests.cs b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentQueryOperationServiceTests.cs
new file mode 100644
index 0000000000..4367595bb7
--- /dev/null
+++ b/tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ContentQueryOperationServiceTests.cs
@@ -0,0 +1,212 @@
+using NUnit.Framework;
+using Umbraco.Cms.Core.Models;
+using Umbraco.Cms.Core.Services;
+using Umbraco.Cms.Tests.Common.Builders;
+using Umbraco.Cms.Tests.Common.Testing;
+using Umbraco.Cms.Tests.Integration.Testing;
+
+namespace Umbraco.Cms.Tests.Integration.Umbraco.Infrastructure.Services;
+
+///
+/// Integration tests for ContentQueryOperationService.
+///
+[TestFixture]
+[UmbracoTest(
+ Database = UmbracoTestOptions.Database.NewSchemaPerTest,
+ WithApplication = true)]
+public class ContentQueryOperationServiceTests : UmbracoIntegrationTestWithContent
+{
+ private IContentQueryOperationService QueryService => GetRequiredService();
+
+ [Test]
+ public void Count_WithNoFilter_ReturnsAllContentCount()
+ {
+ // Arrange - base class creates Textpage, Subpage, Subpage2, Subpage3, Trashed
+
+ // Act
+ var count = QueryService.Count();
+
+ // Assert - should return 5 (all items including Trashed)
+ Assert.That(count, Is.EqualTo(5));
+ }
+
+ [Test]
+ public void Count_WithNonExistentContentTypeAlias_ReturnsZero()
+ {
+ // Arrange
+ var nonExistentAlias = "nonexistent-content-type-alias";
+
+ // Act
+ var count = QueryService.Count(nonExistentAlias);
+
+ // Assert
+ Assert.That(count, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void Count_WithContentTypeAlias_ReturnsFilteredCount()
+ {
+ // Arrange
+ var alias = ContentType.Alias;
+
+ // Act
+ var count = QueryService.Count(alias);
+
+ // Assert - all 5 content items use the same content type
+ Assert.That(count, Is.EqualTo(5));
+ }
+
+ [Test]
+ public void CountChildren_ReturnsChildCount()
+ {
+ // Arrange - Textpage has children: Subpage, Subpage2, Subpage3
+ var parentId = Textpage.Id;
+
+ // Act
+ var count = QueryService.CountChildren(parentId);
+
+ // Assert
+ Assert.That(count, Is.EqualTo(3));
+ }
+
+ [Test]
+ public void GetByLevel_ReturnsContentAtLevel()
+ {
+ // Arrange - level 1 is root content
+
+ // Act
+ var items = QueryService.GetByLevel(1);
+
+ // Assert
+ Assert.That(items, Is.Not.Null);
+ Assert.That(items.All(x => x.Level == 1), Is.True);
+ }
+
+ [Test]
+ public void GetPagedOfType_ReturnsPaginatedResults()
+ {
+ // Arrange
+ var contentTypeId = ContentType.Id;
+
+ // Act
+ var items = QueryService.GetPagedOfType(contentTypeId, 0, 10, out var total);
+
+ // Assert
+ Assert.That(items, Is.Not.Null);
+ Assert.That(total, Is.EqualTo(5)); // All 5 content items are of this type
+ }
+
+ [Test]
+ public void GetPagedOfTypes_WithEmptyArray_ReturnsEmpty()
+ {
+ // Act
+ var items = QueryService.GetPagedOfTypes(Array.Empty(), 0, 10, out var total);
+
+ // Assert
+ Assert.That(items, Is.Empty);
+ Assert.That(total, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void GetPagedOfTypes_WithNonExistentContentTypeIds_ReturnsEmpty()
+ {
+ // Arrange
+ var nonExistentIds = new[] { 999999, 999998 };
+
+ // Act
+ var items = QueryService.GetPagedOfTypes(nonExistentIds, 0, 10, out var total);
+
+ // Assert
+ Assert.That(items, Is.Empty);
+ Assert.That(total, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void CountChildren_WithNonExistentParentId_ReturnsZero()
+ {
+ // Arrange
+ var nonExistentParentId = 999999;
+
+ // Act
+ var count = QueryService.CountChildren(nonExistentParentId);
+
+ // Assert
+ Assert.That(count, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void GetByLevel_WithLevelZero_ReturnsEmpty()
+ {
+ // Arrange - level 0 doesn't exist (content starts at level 1)
+
+ // Act
+ var items = QueryService.GetByLevel(0);
+
+ // Assert
+ Assert.That(items, Is.Empty);
+ }
+
+ [Test]
+ public void GetByLevel_WithNegativeLevel_ReturnsEmpty()
+ {
+ // Arrange
+
+ // Act
+ var items = QueryService.GetByLevel(-1);
+
+ // Assert
+ Assert.That(items, Is.Empty);
+ }
+
+ [Test]
+ public void GetPagedOfType_WithNonExistentContentTypeId_ReturnsEmpty()
+ {
+ // Arrange
+ var nonExistentId = 999999;
+
+ // Act
+ var items = QueryService.GetPagedOfType(nonExistentId, 0, 10, out var total);
+
+ // Assert
+ Assert.That(items, Is.Empty);
+ Assert.That(total, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void CountDescendants_ReturnsDescendantCount()
+ {
+ // Arrange - Textpage has descendants: Subpage, Subpage2, Subpage3
+ var ancestorId = Textpage.Id;
+
+ // Act
+ var count = QueryService.CountDescendants(ancestorId);
+
+ // Assert
+ Assert.That(count, Is.EqualTo(3));
+ }
+
+ [Test]
+ public void CountDescendants_WithNonExistentAncestorId_ReturnsZero()
+ {
+ // Arrange
+ var nonExistentId = 999999;
+
+ // Act
+ var count = QueryService.CountDescendants(nonExistentId);
+
+ // Assert
+ Assert.That(count, Is.EqualTo(0));
+ }
+
+ [Test]
+ public void CountPublished_WithNoPublishedContent_ReturnsZero()
+ {
+ // Arrange - base class creates content but doesn't publish
+
+ // Act
+ var count = QueryService.CountPublished();
+
+ // Assert
+ Assert.That(count, Is.EqualTo(0));
+ }
+}