From bf054e9d6268b0ea26dda4fab3e32e3bb59c958b Mon Sep 17 00:00:00 2001 From: yv01p Date: Sat, 20 Dec 2025 01:36:07 +0000 Subject: [PATCH] docs: add performance benchmarks to ContentService refactor design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added revision 1.5 with 33 performance benchmarks for baseline comparison - Benchmarks cover CRUD (7), Query (6), Publish (7), Move (8), Version (4) operations - Added baseline comparison infrastructure with JSON output format - Includes execution commands and sample comparison output πŸ€– Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- ...25-12-19-contentservice-refactor-design.md | 840 ++++++++++++++++-- 1 file changed, 789 insertions(+), 51 deletions(-) diff --git a/docs/plans/2025-12-19-contentservice-refactor-design.md b/docs/plans/2025-12-19-contentservice-refactor-design.md index 53c94afeba..4effa7f804 100644 --- a/docs/plans/2025-12-19-contentservice-refactor-design.md +++ b/docs/plans/2025-12-19-contentservice-refactor-design.md @@ -1,9 +1,20 @@ # ContentService Refactoring Design **Date**: 2025-12-19 -**Status**: Approved +**Status**: Revised (post-test strategy) **Branch**: refactor/ContentService +### Revision History + +| Rev | Description | +|-----|-------------| +| 1.0 | Initial design document | +| 1.1 | Post-architectural review clarifications | +| 1.2 | Added performance optimizations (database, memory, concurrency, caching) | +| 1.3 | Added detailed test strategy (15 tests for coverage gaps) | +| 1.4 | Added phase gates with test execution commands and regression protocol | +| 1.5 | Added performance benchmarks (33 tests for baseline comparison) | + ## Overview Refactor the `ContentService` class (~3800 lines) into smaller, focused components for improved maintainability, testability, and performance. @@ -21,49 +32,160 @@ Refactor the `ContentService` class (~3800 lines) into smaller, focused componen - Existing public method signatures unchanged - New interfaces can be added - Obsolete constructors to be commented out (not removed) +- **Must avoid naming collision with existing `IContentPublishingService`** (see Naming Decisions) + +## Naming Decisions + +### Resolving IContentPublishingService Collision + +An `IContentPublishingService` already exists in the codebase (`src/Umbraco.Core/Services/ContentPublishingService.cs`). This is an async API-layer service that orchestrates publishing via `IContentService`. + +**Decision**: Rename the proposed publishing interface to `IContentPublishOperationService` to avoid collision. The existing `IContentPublishingService` remains unchanged as the higher-level async API orchestrator. ## Architecture +### Service Dependency Direction + +Services follow a unidirectional dependency pattern (no circular dependencies): + +``` + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ ContentService (Facade) β”‚ + β”‚ - Delegates to all below β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ depends on + β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + β”‚ β”‚ β”‚ + β–Ό β–Ό β–Ό +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ IContentCrud- β”‚ β”‚ IContentPublish- β”‚ β”‚ IContentMove- β”‚ +β”‚ Service β”‚ β”‚ OperationService β”‚ β”‚ Service β”‚ +β”‚ (CRUD only) β”‚ β”‚ (publish/schedule) β”‚ β”‚ (move/copy/bin) β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + β”‚ β”‚ β”‚ + β”‚ β”‚ can call β”‚ can call + β”‚ β–Ό β–Ό + β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” + └──────────► β”‚ IContentCrudService β”‚ + β”‚ (Save/Delete operations as needed) β”‚ + β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +**Rules**: +- `IContentCrudService` has NO dependencies on other content services +- `IContentPublishOperationService` MAY call `IContentCrudService` for Save operations +- `IContentMoveService` MAY call `IContentCrudService` for Save/Delete operations +- The facade orchestrates cross-service operations when needed + ### New Public Service Interfaces (3) | Interface | Responsibility | Est. Lines | |-----------|---------------|------------| | `IContentCrudService` | Create, Get, Save, Delete | ~400 | -| `IContentPublishingService` | Publish, Unpublish, Scheduling, Branch | ~800 | +| `IContentPublishOperationService` | Publish, Unpublish, Scheduling, Branch | ~1000* | | `IContentMoveService` | Move, RecycleBin, Copy, Sort | ~350 | -### Internal Helper Classes (4) +*Revised estimate: `CommitDocumentChangesInternal` alone is 330+ lines + +### New Public Service Interfaces - Revised Classification (5) + +Based on actual API usage patterns, Query and Versioning are promoted to public: + +| Interface | Responsibility | Est. Lines | Visibility | +|-----------|---------------|------------|------------| +| `IContentCrudService` | Create, Get, Save, Delete | ~400 | **Public** | +| `IContentPublishOperationService` | Publish, Unpublish, Scheduling | ~1000 | **Public** | +| `IContentMoveService` | Move, RecycleBin, Copy, Sort | ~350 | **Public** | +| `IContentQueryService` | Count, Paged queries, Hierarchy | ~250 | **Public** | +| `IContentVersionService` | Versions, Rollback, DeleteVersions | ~200 | **Public** | + +### Internal Helper Classes (2) | Class | Responsibility | Est. Lines | |-------|---------------|------------| -| `ContentVersioningHelper` | Versions, Rollback, DeleteVersions | ~150 | -| `ContentQueryHelper` | Count, Paged queries, Hierarchy | ~200 | -| `ContentPermissionHelper` | Get/Set permissions | ~50 | -| `ContentBlueprintHelper` | Blueprint CRUD | ~200 | +| `ContentPermissionManager` | Get/Set permissions | ~50 | +| `ContentBlueprintManager` | Blueprint CRUD | ~200 | + +*Renamed from "Helper" to "Manager" per .NET naming conventions* ### ContentService Facade (~200 lines) -Thin wrapper delegating to services and helpers for backward compatibility. +Thin wrapper delegating to services and managers for backward compatibility. + +## Transaction/Scope Ownership + +### Scope Creation Rules + +**Pattern: Caller-Creates-Scope (Ambient Scope)** + +All public service methods check for an existing ambient scope and use it if present. If no ambient scope exists, the method creates its own. + +```csharp +public OperationResult Save(IContent content, int? userId = null) +{ + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + // ... implementation + scope.Complete(); + return result; +} +``` + +**Cross-Service Call Behavior**: +- When Service A calls Service B, Service B detects the ambient scope from A +- Nested scopes participate in the parent transaction +- Only the outermost scope's `Complete()` commits the transaction +- If any nested scope fails to complete, the entire transaction rolls back + +**Facade Orchestration**: +For operations requiring multiple services (e.g., `MoveToRecycleBin` which may need to unpublish), the facade creates the scope: + +```csharp +// In ContentService facade +public OperationResult MoveToRecycleBin(IContent content, int userId) +{ + using ICoreScope scope = ScopeProvider.CreateCoreScope(); + + // Unpublish if published (uses ambient scope) + if (content.Published) + { + _publishOperationService.Unpublish(content, "*", userId); + } + + // Move to bin (uses same ambient scope) + var result = _moveService.MoveToRecycleBinInternal(content, userId); + + scope.Complete(); + return result; +} +``` + +### Lock Coordination + +Write locks (`scope.WriteLock(Constants.Locks.ContentTree)`) are acquired by the service performing the operation. When services call each other within an ambient scope, locks are already held by the parent. + +**Rule**: Acquire locks at the highest level that creates the scope. ## File Structure ``` src/Umbraco.Core/Services/ β”œβ”€β”€ IContentCrudService.cs -β”œβ”€β”€ IContentPublishingService.cs +β”œβ”€β”€ IContentPublishOperationService.cs # Renamed to avoid collision β”œβ”€β”€ IContentMoveService.cs +β”œβ”€β”€ IContentQueryService.cs # Promoted to public +β”œβ”€β”€ IContentVersionService.cs # Promoted to public └── ContentService.cs (facade - existing file, refactored) src/Umbraco.Infrastructure/Services/ β”œβ”€β”€ ContentCrudService.cs -β”œβ”€β”€ ContentPublishingService.cs +β”œβ”€β”€ ContentPublishOperationService.cs # Renamed to avoid collision β”œβ”€β”€ ContentMoveService.cs +β”œβ”€β”€ ContentQueryService.cs # Promoted to public +β”œβ”€β”€ ContentVersionService.cs # Promoted to public β”œβ”€β”€ ContentServiceBase.cs (shared infrastructure) -└── Helpers/ - β”œβ”€β”€ ContentVersioningHelper.cs - β”œβ”€β”€ ContentQueryHelper.cs - β”œβ”€β”€ ContentPermissionHelper.cs - └── ContentBlueprintHelper.cs +└── Content/ + β”œβ”€β”€ ContentPermissionManager.cs # Renamed from Helper + └── ContentBlueprintManager.cs # Renamed from Helper ``` ## Detailed Interface Designs @@ -99,10 +221,12 @@ public interface IContentCrudService } ``` -### IContentPublishingService +### IContentPublishOperationService + +*Renamed from `IContentPublishingService` to avoid collision with existing API-layer service.* ```csharp -public interface IContentPublishingService +public interface IContentPublishOperationService { // Publishing PublishResult Publish(IContent content, string[] cultures, int userId = Constants.Security.SuperUserId); @@ -125,6 +249,9 @@ public interface IContentPublishingService // Path/Status Checks bool IsPathPublishable(IContent content); bool IsPathPublished(IContent? content); + + // Send to Publication (workflow) + bool SendToPublication(IContent? content, int userId = Constants.Security.SuperUserId); } ``` @@ -153,45 +280,57 @@ public interface IContentMoveService } ``` -### Internal Helpers +### IContentVersionService (Promoted to Public) ```csharp -internal class ContentVersioningHelper +public interface IContentVersionService { IContent? GetVersion(int versionId); IEnumerable GetVersions(int id); IEnumerable GetVersionsSlim(int id, int skip, int take); IEnumerable GetVersionIds(int id, int maxRows); - OperationResult Rollback(int id, int versionId, string culture, int userId); - void DeleteVersions(int id, DateTime versionDate, int userId); - void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId); + OperationResult Rollback(int id, int versionId, string culture = "*", int userId = Constants.Security.SuperUserId); + void DeleteVersions(int id, DateTime versionDate, int userId = Constants.Security.SuperUserId); + void DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId = Constants.Security.SuperUserId); } +``` -internal class ContentQueryHelper +### IContentQueryService (Promoted to Public) + +```csharp +public interface IContentQueryService { + // Counting int Count(string? contentTypeAlias = null); int CountPublished(string? contentTypeAlias = null); int CountChildren(int parentId, string? contentTypeAlias = null); int CountDescendants(int parentId, string? contentTypeAlias = null); + bool HasChildren(int id); + + // Hierarchy IEnumerable GetByLevel(int level); IEnumerable GetAncestors(int id); IEnumerable GetAncestors(IContent content); - IEnumerable GetPublishedChildren(int id); - bool HasChildren(int id); - IEnumerable GetPagedChildren(...); - IEnumerable GetPagedDescendants(...); - IEnumerable GetPagedOfType(...); - IEnumerable GetPagedOfTypes(...); -} -internal class ContentPermissionHelper + // Paged Queries + IEnumerable GetPagedChildren(int id, long pageIndex, int pageSize, out long totalRecords, IQuery? filter = null, Ordering? ordering = null); + IEnumerable GetPagedDescendants(int id, long pageIndex, int pageSize, out long totalRecords, IQuery? filter = null, Ordering? ordering = null); + IEnumerable GetPagedOfType(int contentTypeId, long pageIndex, int pageSize, out long totalRecords, IQuery filter, Ordering? ordering = null); + IEnumerable GetPagedOfTypes(int[] contentTypeIds, long pageIndex, int pageSize, out long totalRecords, IQuery? filter, Ordering? ordering = null); +} +``` + +### Internal Managers + +```csharp +internal class ContentPermissionManager { void SetPermissions(EntityPermissionSet permissionSet); void SetPermission(IContent entity, string permission, IEnumerable groupIds); EntityPermissionCollection GetPermissions(IContent content); } -internal class ContentBlueprintHelper +internal class ContentBlueprintManager { IContent? GetBlueprintById(int id); IContent? GetBlueprintById(Guid id); @@ -199,6 +338,7 @@ internal class ContentBlueprintHelper void DeleteBlueprint(IContent content, int userId); IContent CreateBlueprintFromContent(IContent blueprint, string name, int userId); IEnumerable GetBlueprintsForContentTypes(params int[] contentTypeId); + void DeleteBlueprintsOfType(int contentTypeId, int userId); void DeleteBlueprintsOfTypes(IEnumerable contentTypeIds, int userId); } ``` @@ -227,14 +367,14 @@ public class ContentServicesComposer : IComposer { // Public services builder.Services.AddScoped(); - builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); - // Internal helpers - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); - builder.Services.AddScoped(); + // Internal managers + builder.Services.AddScoped(); + builder.Services.AddScoped(); // Facade (backward compatible) builder.Services.AddScoped(); @@ -244,35 +384,633 @@ public class ContentServicesComposer : IComposer ## Implementation Order -1. **Phase 1: CRUD Service** - Establish patterns -2. **Phase 2: Publishing Service** - Most complex, tackle early -3. **Phase 3: Move Service** - Depends on CRUD/Publishing -4. **Phase 4: Versioning Helper** - Depends on CRUD -5. **Phase 5: Query Helper** - Utility operations -6. **Phase 6: Permission Helper** - Small extraction -7. **Phase 7: Blueprint Helper** - Final cleanup -8. **Phase 8: Facade** - Wire everything together +Each phase MUST run tests before and after to verify no regressions. + +| Phase | Service | Tests to Run | Gate | +|-------|---------|--------------|------| +| 0 | Write tests | `ContentServiceRefactoringTests` | All 15 pass | +| 1 | CRUD Service | All ContentService*Tests | All pass | +| 2 | Query Service | All ContentService*Tests | All pass | +| 3 | Version Service | All ContentService*Tests | All pass | +| 4 | Move Service | All ContentService*Tests + Sort/MoveToRecycleBin tests | All pass | +| 5 | Publish Operation Service | All ContentService*Tests + Notification ordering tests | All pass | +| 6 | Permission Manager | All ContentService*Tests + Permission tests | All pass | +| 7 | Blueprint Manager | All ContentService*Tests | All pass | +| 8 | Facade | **Full test suite** | All pass | + +### Phase Details + +1. **Phase 0: Write Tests** - Create `ContentServiceRefactoringTests.cs` with all 15 tests +2. **Phase 1: CRUD Service** - Establish patterns, base class +3. **Phase 2: Query Service** - Read-only operations, low risk +4. **Phase 3: Version Service** - Straightforward extraction +5. **Phase 4: Move Service** - Depends on CRUD; Sort and MoveToRecycleBin tests critical +6. **Phase 5: Publish Operation Service** - Most complex; notification ordering tests critical +7. **Phase 6: Permission Manager** - Small extraction; permission tests critical +8. **Phase 7: Blueprint Manager** - Final cleanup +9. **Phase 8: Facade** - Wire everything together, add async methods + +### Test Execution Commands + +```bash +# Run refactoring-specific tests (fast feedback) +dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentServiceRefactoringTests" + +# Run all ContentService tests (phase gate) +dotnet test tests/Umbraco.Tests.Integration --filter "FullyQualifiedName~ContentService" + +# Run full integration test suite (final gate) +dotnet test tests/Umbraco.Tests.Integration +``` + +### Regression Protocol + +If any test fails during a phase: +1. **STOP** - Do not proceed to next phase +2. **DIAGNOSE** - Identify which behavior changed +3. **FIX** - Restore expected behavior +4. **VERIFY** - Re-run all tests for current phase +5. **CONTINUE** - Only after all tests pass + +## Complete Method Mapping + +This table maps ALL methods from `IContentService` (and its base `IContentServiceBase`) to the proposed services/managers. + +### IContentCrudService Methods + +| Current Method | Target | Notes | +|----------------|--------|-------| +| `Create(string name, Guid parentId, string docTypeAlias, int userId)` | IContentCrudService | | +| `Create(string name, int parentId, string docTypeAlias, int userId)` | IContentCrudService | | +| `Create(string name, int parentId, IContentType contentType, int userId)` | IContentCrudService | | +| `Create(string name, IContent? parent, string docTypeAlias, int userId)` | IContentCrudService | | +| `CreateAndSave(string name, int parentId, string contentTypeAlias, int userId)` | IContentCrudService | Calls Save internally | +| `CreateAndSave(string name, IContent parent, string contentTypeAlias, int userId)` | IContentCrudService | Calls Save internally | +| `GetById(int id)` | IContentCrudService | | +| `GetById(Guid key)` | IContentCrudService | | +| `GetByIds(IEnumerable ids)` | IContentCrudService | | +| `GetByIds(IEnumerable ids)` | IContentCrudService | | +| `GetRootContent()` | IContentCrudService | | +| `GetParent(int id)` | IContentCrudService | | +| `GetParent(IContent content)` | IContentCrudService | | +| `Save(IContent content, int? userId, ContentScheduleCollection? schedule)` | IContentCrudService | | +| `Save(IEnumerable contents, int userId)` | IContentCrudService | | +| `Delete(IContent content, int userId)` | IContentCrudService | | +| `DeleteOfType(int documentTypeId, int userId)` | IContentCrudService | *Orchestration: moves descendants to bin first* | +| `DeleteOfTypes(IEnumerable contentTypeIds, int userId)` | IContentCrudService | *Orchestration: moves descendants to bin first* | + +### IContentPublishOperationService Methods + +| Current Method | Target | Notes | +|----------------|--------|-------| +| `Publish(IContent content, string[] cultures, int userId)` | IContentPublishOperationService | | +| `PublishBranch(IContent content, PublishBranchFilter filter, string[] cultures, int userId)` | IContentPublishOperationService | Complex: ~200 lines | +| `Unpublish(IContent content, string? culture, int userId)` | IContentPublishOperationService | | +| `IsPathPublishable(IContent content)` | IContentPublishOperationService | Read-only | +| `IsPathPublished(IContent content)` | IContentPublishOperationService | Read-only | +| `SendToPublication(IContent? content, int userId)` | IContentPublishOperationService | Workflow trigger | +| `PerformScheduledPublish(DateTime date)` | IContentPublishOperationService | Scheduled job entry point | +| `GetContentForExpiration(DateTime date)` | IContentPublishOperationService | Schedule query | +| `GetContentForRelease(DateTime date)` | IContentPublishOperationService | Schedule query | +| `GetContentScheduleByContentId(int contentId)` | IContentPublishOperationService | | +| `GetContentScheduleByContentId(Guid contentId)` | IContentPublishOperationService | | +| `PersistContentSchedule(IContent content, ContentScheduleCollection schedule)` | IContentPublishOperationService | | +| `GetContentSchedulesByIds(Guid[] keys)` | IContentPublishOperationService | Bulk query | + +### IContentMoveService Methods + +| Current Method | Target | Notes | +|----------------|--------|-------| +| `Move(IContent content, int parentId, int userId)` | IContentMoveService | | +| `MoveToRecycleBin(IContent content, int userId)` | **Facade** | *Orchestration: may unpublish first* | +| `EmptyRecycleBin(int userId)` | IContentMoveService | | +| `EmptyRecycleBinAsync(Guid userId)` | IContentMoveService | Async variant | +| `RecycleBinSmells()` | IContentMoveService | | +| `GetPagedContentInRecycleBin(...)` | IContentMoveService | | +| `Copy(IContent content, int parentId, bool relateToOriginal, int userId)` | IContentMoveService | | +| `Copy(IContent content, int parentId, bool relateToOriginal, bool recursive, int userId)` | IContentMoveService | | +| `Sort(IEnumerable items, int userId)` | IContentMoveService | | +| `Sort(IEnumerable? ids, int userId)` | IContentMoveService | | + +### IContentQueryService Methods + +| Current Method | Target | Notes | +|----------------|--------|-------| +| `Count(string? documentTypeAlias)` | IContentQueryService | | +| `CountPublished(string? documentTypeAlias)` | IContentQueryService | | +| `CountChildren(int parentId, string? documentTypeAlias)` | IContentQueryService | | +| `CountDescendants(int parentId, string? documentTypeAlias)` | IContentQueryService | | +| `HasChildren(int id)` | IContentQueryService | | +| `GetByLevel(int level)` | IContentQueryService | | +| `GetAncestors(int id)` | IContentQueryService | | +| `GetAncestors(IContent content)` | IContentQueryService | | +| `GetPagedChildren(int id, ...)` | IContentQueryService | | +| `GetPagedDescendants(int id, ...)` | IContentQueryService | | +| `GetPagedOfType(int contentTypeId, ...)` | IContentQueryService | | +| `GetPagedOfTypes(int[] contentTypeIds, ...)` | IContentQueryService | | + +### IContentVersionService Methods + +| Current Method | Target | Notes | +|----------------|--------|-------| +| `GetVersion(int versionId)` | IContentVersionService | | +| `GetVersions(int id)` | IContentVersionService | | +| `GetVersionsSlim(int id, int skip, int take)` | IContentVersionService | | +| `GetVersionIds(int id, int topRows)` | IContentVersionService | | +| `Rollback(int id, int versionId, string culture, int userId)` | IContentVersionService | | +| `DeleteVersions(int id, DateTime date, int userId)` | IContentVersionService | | +| `DeleteVersion(int id, int versionId, bool deletePriorVersions, int userId)` | IContentVersionService | | + +### ContentPermissionManager Methods (Internal) + +| Current Method | Target | Notes | +|----------------|--------|-------| +| `GetPermissions(IContent content)` | ContentPermissionManager | | +| `SetPermissions(EntityPermissionSet permissionSet)` | ContentPermissionManager | | +| `SetPermission(IContent entity, string permission, IEnumerable groupIds)` | ContentPermissionManager | | + +### ContentBlueprintManager Methods (Internal) + +| Current Method | Target | Notes | +|----------------|--------|-------| +| `GetBlueprintById(int id)` | ContentBlueprintManager | | +| `GetBlueprintById(Guid id)` | ContentBlueprintManager | | +| `GetBlueprintsForContentTypes(params int[] documentTypeId)` | ContentBlueprintManager | | +| `SaveBlueprint(IContent content, int userId)` | ContentBlueprintManager | Obsolete overload | +| `SaveBlueprint(IContent content, IContent? createdFromContent, int userId)` | ContentBlueprintManager | | +| `DeleteBlueprint(IContent content, int userId)` | ContentBlueprintManager | | +| `CreateBlueprintFromContent(IContent blueprint, string name, int userId)` | ContentBlueprintManager | | +| `CreateContentFromBlueprint(IContent blueprint, string name, int userId)` | ContentBlueprintManager | Obsolete | +| `DeleteBlueprintsOfType(int contentTypeId, int userId)` | ContentBlueprintManager | | +| `DeleteBlueprintsOfTypes(IEnumerable contentTypeIds, int userId)` | ContentBlueprintManager | | + +### Facade Orchestration Methods + +These methods stay in the facade because they coordinate multiple services: + +| Method | Why in Facade | +|--------|---------------| +| `MoveToRecycleBin` | Unpublishes content (IContentPublishOperationService) then moves (IContentMoveService) | +| `DeleteOfType` / `DeleteOfTypes` | Moves descendants to bin first, then deletes type content | + +## Notification Responsibility Matrix + +Each service fires only its own notifications. State is preserved via `WithStateFrom()` for notification continuity. + +| Notification | Firing Service | Notes | +|--------------|----------------|-------| +| `ContentSavingNotification` | IContentCrudService | Cancellable | +| `ContentSavedNotification` | IContentCrudService | | +| `ContentDeletingNotification` | IContentCrudService | Cancellable | +| `ContentDeletedNotification` | IContentCrudService | | +| `ContentPublishingNotification` | IContentPublishOperationService | Cancellable | +| `ContentPublishedNotification` | IContentPublishOperationService | | +| `ContentUnpublishingNotification` | IContentPublishOperationService | Cancellable | +| `ContentUnpublishedNotification` | IContentPublishOperationService | | +| `ContentMovingNotification` | IContentMoveService | Cancellable | +| `ContentMovedNotification` | IContentMoveService | | +| `ContentMovingToRecycleBinNotification` | **Facade** | Orchestrated operation | +| `ContentMovedToRecycleBinNotification` | **Facade** | Orchestrated operation | +| `ContentCopyingNotification` | IContentMoveService | Cancellable | +| `ContentCopiedNotification` | IContentMoveService | | +| `ContentSortingNotification` | IContentMoveService | Cancellable | +| `ContentSortedNotification` | IContentMoveService | | +| `ContentEmptyingRecycleBinNotification` | IContentMoveService | Cancellable | +| `ContentEmptiedRecycleBinNotification` | IContentMoveService | | +| `ContentRollingBackNotification` | IContentVersionService | Cancellable | +| `ContentRolledBackNotification` | IContentVersionService | | +| `ContentSendingToPublishNotification` | IContentPublishOperationService | Cancellable | +| `ContentSentToPublishNotification` | IContentPublishOperationService | | +| `ContentSavingBlueprintNotification` | ContentBlueprintManager | Via IEventAggregator | +| `ContentSavedBlueprintNotification` | ContentBlueprintManager | Via IEventAggregator | +| `ContentDeletedBlueprintNotification` | ContentBlueprintManager | Via IEventAggregator | + +**State Preservation Rule**: When a method chains operations (e.g., Save β†’ Publish), the second notification carries state from the first using `WithStateFrom()`. ## Risk Mitigation | Risk | Mitigation | |------|------------| -| Circular dependencies | Inject repository directly where needed | -| Transaction boundaries | Ensure scopes work across service calls | -| Notification consistency | Events fire from correct service | +| Circular dependencies | Unidirectional: Publish/Move β†’ CRUD only; no reverse | +| Transaction boundaries | Ambient scope pattern (see Transaction/Scope Ownership) | +| Notification consistency | Notification responsibility matrix per service | | Breaking changes | Extensive test coverage before refactor | +| Naming collision | Renamed to IContentPublishOperationService | +| Cross-service calls | Facade orchestrates complex multi-service operations | + +## Async Considerations + +Methods with async variants should define async-first interfaces where database I/O occurs: + +| Current Method | Async Variant Needed | +|----------------|---------------------| +| `EmptyRecycleBin` | βœ… Already exists: `EmptyRecycleBinAsync` | +| `Delete` (bulk) | Consider for large deletions | +| `PublishBranch` | Consider for large trees | + +**Decision**: New services should provide async overloads for operations that can process large datasets. + +## Clarifications (from Architectural Review) + +### Q1: Existing IContentPublishingService handling? +**A**: The existing `IContentPublishingService` (API-layer) remains unchanged. It continues to orchestrate via `IContentService`. After refactoring, it will indirectly use the new `IContentPublishOperationService` through the facade. + +### Q2: PublishBranch ownership? +**A**: `PublishBranch` belongs entirely to `IContentPublishOperationService`. Tree traversal is part of publishing logic in this context. + +### Q3: Locking strategy? +**A**: Locks are acquired by the scope creator (highest-level caller). Nested service calls within an ambient scope inherit the existing locks. + +### Q4: Culture-variant complexity (CommitDocumentChangesInternal)? +**A**: The full complexity (~330 lines) stays in `ContentPublishOperationService`. No further splitting at this time; revisit if the class exceeds 1200 lines. + +### Q5: RepositoryService base class? +**A**: New services inherit from `ContentServiceBase` (see Shared Infrastructure), which wraps scope/repository access similarly to `RepositoryService` but is specific to content operations. + +## Performance Optimizations + +Performance improvements to implement during the refactoring, organized into four areas with incremental steps. + +### 1. Database Query Efficiency + +**Approach**: Batch lookups to eliminate N+1 queries. + +| Step | Change | +|------|--------| +| 1 | Add `IIdKeyMap.GetIdsForKeys(Guid[] keys, UmbracoObjectTypes type)` for batch key-to-id resolution | +| 2 | Add `GetSchedulesByContentIds(int[] ids)` to batch schedule lookups | +| 3 | Add `ArePathsPublished(int[] contentIds)` for batch path validation | +| 4 | Add `GetParents(int[] contentIds)` for batch ancestor lookups | + +**Key locations to fix**: +- `GetContentSchedulesByIds` (line 1025-1049): N+1 in `_idKeyMap.GetIdForKey` calls +- `CommitDocumentChangesInternal` (line 1461+): Multiple repository calls in same scope +- `IsPathPublishable` (line 1070), `GetAncestors` (line 792): Repeated single-item lookups + +### 2. Memory Allocation Patterns + +**Approach**: Aggressive optimization, implemented incrementally. + +| Step | Focus | Changes | +|------|-------|---------| +| 1 | Remove unnecessary materializations | Delete `.ToArray()`/`.ToList()` before `foreach` loops | +| 2 | Fix string allocations | Replace `+` concat in loops with `StringBuilder` | +| 3 | Add StringBuilder pooling | Use `StringBuilderCache` or `ObjectPool` | +| 4 | Eliminate closure allocations | Hoist lambdas, use static lambdas where possible | +| 5 | Span for string operations | Replace substrings with `ReadOnlySpan` | +| 6 | ArrayPool for temp arrays | Pool arrays in batch operations | +| 7 | Pooled collections | Use `ArrayPoolList` or similar for hot paths | + +**Key locations to fix**: +- Lines 1170, 814, 2650: ToArray/ToList before iteration +- Lines 1201, 2596-2598: String concat in loops +- Lines 1125-1127: Lambda/closure on every save +- Lines 555-556: Dictionary recreation + +### 3. Concurrency & Locking + +**Approach**: Moderate optimization focused on lock duration and correctness. + +| Step | Focus | Changes | +|------|-------|---------| +| 1 | Remove unnecessary read locks | Skip `ReadLock` when data is retrieved from cache | +| 2 | Lock before notification | Move `WriteLock` acquisition before `*SavingNotification` | +| 3 | Batch descendant operations | In `PerformMoveLocked`, collect changes then batch-save | +| 4 | Document lock contracts | Each new service documents its lock expectations | + +**Current issues**: +- Single coarse-grained lock (`ContentTree`) for all 60+ lock calls +- Long lock hold in `PerformMoveLocked` (line 2600+) during descendant iteration +- Lock acquired after notification (line 1114) creates race window + +### 4. Caching Strategies + +**Approach**: Moderate optimization with clear invalidation contracts. + +| Step | Focus | Changes | +|------|-------|---------| +| 1 | Audit RefreshBranch usage | Switch to `RefreshNode` where only single node affected | +| 2 | Parallelize cache clears | Run Memory, Navigation, Routing, Published status clears concurrently | +| 3 | Fix N+1 in HandleNavigation | Batch descendant lookups instead of per-item queries | +| 4 | Document invalidation contracts | Each service specifies which cache events it fires | + +**Cache invalidation flow**: +``` +ContentService operation + β†’ ContentTreeChangeNotification + β†’ ContentTreeChangeDistributedCacheNotificationHandler + β†’ ContentCacheRefresher.Refresh() + β†’ Repository cache, Elements cache, Navigation, URLs, Published status +``` + +**Known limitations (out of scope)**: +- Elements cache full clear: Requires architectural change (elements as entities, not JSON blobs) +- Sync-over-async in cache refresh: Cross-cutting concern for separate initiative ## Testing Strategy +### Overview + 1. Ensure existing unit tests pass throughout refactor -2. Add new unit tests for each extracted service +2. Add new integration tests for identified coverage gaps (15 tests) 3. Integration tests validate end-to-end behavior unchanged 4. Benchmark critical paths before/after +### Test File Organization + +Create a single new test file for refactoring-specific coverage: + +``` +tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ + ContentServiceRefactoringTests.cs +``` + +**Rationale**: These tests exist specifically to validate the refactoring. Grouping them together: +- Makes clear which tests are refactoring-specific safety nets +- Can be retired or merged once refactoring is stable +- Avoids polluting existing test files + +### Test Coverage Summary + +| Gap Area | Test Count | Purpose | +|----------|------------|---------| +| Notification ordering | 2 | MoveToRecycleBin unpublishβ†’move sequence | +| Sort operation | 3 | Sort(IContent), Sort(ids), notification firing | +| DeleteOfType | 3 | Descendants handling, mixed types, multiple types | +| Permissions | 4 | SetPermission, SetPermissions, GetPermissions | +| Transaction boundaries | 3 | Ambient scope across chained operations | +| **Total** | **15** | | + +### Notification Handler Extension + +The existing `ContentNotificationHandler` pattern needs extension for new notifications: + +```csharp +internal sealed class ContentNotificationHandler : + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, + INotificationHandler, // NEW + INotificationHandler, // NEW + INotificationHandler, // NEW + INotificationHandler, // NEW + INotificationHandler +{ + // Existing delegates... + + // NEW delegates + public static Action MovingToRecycleBin { get; set; } + public static Action MovedToRecycleBin { get; set; } + public static Action Sorting { get; set; } + public static Action Sorted { get; set; } + + // NEW handlers + public void Handle(ContentMovingToRecycleBinNotification notification) + => MovingToRecycleBin?.Invoke(notification); + public void Handle(ContentMovedToRecycleBinNotification notification) + => MovedToRecycleBin?.Invoke(notification); + public void Handle(ContentSortingNotification notification) + => Sorting?.Invoke(notification); + public void Handle(ContentSortedNotification notification) + => Sorted?.Invoke(notification); +} +``` + +### Detailed Test Specifications + +#### 1. Notification Ordering Tests (2 tests) + +**Test 1: MoveToRecycleBin_PublishedContent_UnpublishesBeforeMoving** + +Verifies that for published content, `MoveToRecycleBin` fires notifications in order: +1. `ContentUnpublishingNotification` +2. `ContentUnpublishedNotification` +3. `ContentMovingToRecycleBinNotification` +4. `ContentMovedToRecycleBinNotification` + +**Test 2: MoveToRecycleBin_UnpublishedContent_OnlyFiresMoveNotifications** + +Verifies that for unpublished content, only move notifications fire (no unpublish notifications). + +#### 2. Sort Operation Tests (3 tests) + +**Test 3: Sort_WithContentItems_ChangesSortOrder** + +Verifies `Sort(IEnumerable)` correctly reorders children. + +**Test 4: Sort_WithIds_ChangesSortOrder** + +Verifies `Sort(IEnumerable)` correctly reorders children by ID. + +**Test 5: Sort_FiresSortingAndSortedNotifications** + +Verifies `ContentSortingNotification` and `ContentSortedNotification` fire in sequence with correct entities. + +#### 3. DeleteOfType Tests (3 tests) + +**Test 6: DeleteOfType_MovesDescendantsToRecycleBinFirst** + +Verifies hierarchical content (parent β†’ child β†’ grandchild) is fully deleted and recycle bin is empty afterward. + +**Test 7: DeleteOfType_WithMixedTypes_OnlyDeletesSpecifiedType** + +Verifies only content of the specified type is deleted; other types remain. + +**Test 8: DeleteOfTypes_DeletesMultipleTypesAtOnce** + +Verifies `DeleteOfTypes(IEnumerable)` deletes multiple content types in single operation. + +#### 4. Permission Tests (4 tests) + +**Test 9: SetPermission_AssignsPermissionToUserGroup** + +Verifies `SetPermission` assigns a permission and `GetPermissions` retrieves it. + +**Test 10: SetPermission_MultiplePermissionsForSameGroup** + +Verifies multiple `SetPermission` calls accumulate permissions for a user group. + +**Test 11: SetPermissions_AssignsPermissionSet** + +Verifies `SetPermissions(EntityPermissionSet)` assigns a complete permission set. + +**Test 12: SetPermission_AssignsToMultipleUserGroups** + +Verifies single `SetPermission` call can assign to multiple user groups simultaneously. + +#### 5. Transaction Boundary Tests (3 tests) + +**Test 13: AmbientScope_NestedOperationsShareTransaction** + +Verifies that multiple operations within an uncompleted scope all roll back together. + +**Test 14: AmbientScope_CompletedScopeCommitsAllOperations** + +Verifies that multiple operations within a completed scope all commit together. + +**Test 15: AmbientScope_MoveToRecycleBinRollsBackCompletely** + +Verifies that `MoveToRecycleBin` (which internally unpublishes then moves) rolls back both operations when scope is not completed. + ## Success Criteria - [ ] All existing tests pass - [ ] No public API breaking changes - [ ] ContentService reduced to ~200 lines - [ ] Each new service independently testable -- [ ] No performance regression +- [ ] Notification ordering matches current behavior +- [ ] All 80+ IContentService methods mapped to new services + +### Test Coverage Criteria + +- [ ] 15 new integration tests written and passing (ContentServiceRefactoringTests.cs) +- [ ] Notification ordering tests verify MoveToRecycleBin sequence +- [ ] Sort operation has full test coverage +- [ ] DeleteOfType/DeleteOfTypes descendant handling verified +- [ ] Permission operations (SetPermission, SetPermissions) tested +- [ ] Transaction boundary tests confirm ambient scope behavior + +### Performance Criteria + +- [ ] N+1 queries eliminated (batch lookups implemented) +- [ ] No unnecessary ToArray/ToList materializations in hot paths +- [ ] Lock duration reduced for bulk operations +- [ ] Each service documents its lock and cache invalidation contracts +- [ ] Benchmarks show no regression (target: 10%+ improvement on batch operations) + +## Performance Benchmarks + +Dedicated benchmark suite for capturing baseline metrics and comparing before/after refactoring. + +### Benchmark File + +``` +tests/Umbraco.Tests.Integration/Umbraco.Infrastructure/Services/ + ContentServiceRefactoringBenchmarks.cs +``` + +### Benchmark Infrastructure + +**BenchmarkResult model:** +```csharp +public record BenchmarkResult( + string Name, + long ElapsedMs, + int ItemCount, + double MsPerItem, + DateTime Timestamp); +``` + +**Output format** (JSON for comparison): +```json +{ + "runDate": "2025-12-20T10:30:00Z", + "branch": "refactor/ContentService", + "commit": "f4a01ed", + "results": { + "Save_BatchOf100": { "elapsedMs": 245, "itemCount": 100, "msPerItem": 2.45 }, + "GetAncestors_DeepHierarchy": { "elapsedMs": 89, "itemCount": 10, "msPerItem": 8.9 } + } +} +``` + +### CRUD Operation Benchmarks (7 tests) + +| Test Name | Operation | Data Size | What It Measures | +|-----------|-----------|-----------|------------------| +| `Save_SingleItem` | `Save(IContent)` | 1 | Single save latency | +| `Save_BatchOf100` | `Save(IEnumerable)` | 100 | Batch save throughput | +| `Save_BatchOf1000` | `Save(IEnumerable)` | 1000 | Large batch scalability | +| `GetById_Single` | `GetById(int)` | 1 | Single retrieval latency | +| `GetByIds_BatchOf100` | `GetByIds(IEnumerable)` | 100 | Batch retrieval (N+1 detection) | +| `Delete_SingleItem` | `Delete(IContent)` | 1 | Single delete latency | +| `Delete_WithDescendants` | `Delete(IContent)` | Parent + 50 children | Cascade delete performance | + +### Query Operation Benchmarks (6 tests) + +| Test Name | Operation | Data Size | What It Measures | +|-----------|-----------|-----------|------------------| +| `GetPagedChildren_100Items` | `GetPagedChildren()` | 100 children | Paged query performance | +| `GetPagedDescendants_DeepTree` | `GetPagedDescendants()` | 3 levels, 300 total | Tree traversal efficiency | +| `GetAncestors_DeepHierarchy` | `GetAncestors()` | 10-level deep item | Ancestor lookup (N+1 prone) | +| `Count_ByContentType` | `Count(alias)` | 500 items of type | Count query optimization | +| `CountDescendants_LargeTree` | `CountDescendants()` | 500 descendants | Descendant counting | +| `HasChildren_100Nodes` | `HasChildren()` Γ— 100 | 100 parent nodes | Repeated single lookups | + +### Publish Operation Benchmarks (7 tests) + +| Test Name | Operation | Data Size | What It Measures | +|-----------|-----------|-----------|------------------| +| `Publish_SingleItem` | `Publish()` | 1 | Single publish latency | +| `Publish_BatchOf50` | `Publish()` Γ— 50 | 50 items | Repeated publish overhead | +| `PublishBranch_ShallowTree` | `PublishBranch()` | 1 parent + 20 children | Branch publish (small) | +| `PublishBranch_DeepTree` | `PublishBranch()` | 5 levels, 100 items | Branch publish scalability | +| `Unpublish_SingleItem` | `Unpublish()` | 1 | Single unpublish latency | +| `PerformScheduledPublish` | `PerformScheduledPublish()` | 50 scheduled items | Scheduled job performance | +| `GetContentSchedulesByIds_100Items` | `GetContentSchedulesByIds()` | 100 items | N+1 issue (line 1025-1049) | + +### Move Operation Benchmarks (8 tests) + +| Test Name | Operation | Data Size | What It Measures | +|-----------|-----------|-----------|------------------| +| `Move_SingleItem` | `Move()` | 1 | Single move latency | +| `Move_WithDescendants` | `Move()` | 1 parent + 50 children | Descendant path updates | +| `MoveToRecycleBin_Published` | `MoveToRecycleBin()` | 1 published item | Unpublish + move orchestration | +| `MoveToRecycleBin_LargeTree` | `MoveToRecycleBin()` | 100 descendants | Lock duration (line 2600+) | +| `Copy_SingleItem` | `Copy()` | 1 | Single copy latency | +| `Copy_Recursive_50Items` | `Copy(..., recursive: true)` | 50 descendants | Recursive copy overhead | +| `Sort_100Children` | `Sort(IEnumerable)` | 100 siblings | Sort operation efficiency | +| `EmptyRecycleBin_100Items` | `EmptyRecycleBin()` | 100 items in bin | Bulk delete performance | + +### Version Operation Benchmarks (4 tests) + +| Test Name | Operation | Data Size | What It Measures | +|-----------|-----------|-----------|------------------| +| `GetVersions_ItemWith50Versions` | `GetVersions()` | 50 versions | Version history retrieval | +| `GetVersionsSlim_Paged` | `GetVersionsSlim(skip, take)` | 50 versions, page of 10 | Paged version query | +| `Rollback_ToVersion` | `Rollback()` | 1 item with 10 versions | Rollback latency | +| `DeleteVersions_ByDate` | `DeleteVersions()` | 50 versions | Version cleanup performance | + +### Baseline Comparison (1 test) + +| Test Name | Purpose | +|-----------|---------| +| `CompareAgainstBaseline` | Compares current run against saved baseline, fails if >20% regression | + +### Benchmark Summary + +| Category | Test Count | +|----------|------------| +| CRUD Operations | 7 | +| Query Operations | 6 | +| Publish Operations | 7 | +| Move Operations | 8 | +| Version Operations | 4 | +| Comparison | 1 | +| **Total** | **33** | + +### Benchmark Execution Commands + +```bash +# Run all benchmarks and save as baseline (before refactoring) +dotnet test tests/Umbraco.Tests.Integration \ + --filter "Category=Benchmark&FullyQualifiedName~ContentServiceRefactoringBenchmarks" \ + -- TestRunParameters.Parameter(name="SaveAsBaseline", value="true") + +# Run benchmarks and compare against baseline (after each phase) +dotnet test tests/Umbraco.Tests.Integration \ + --filter "Category=Benchmark&FullyQualifiedName~ContentServiceRefactoringBenchmarks" +``` + +### Sample Comparison Output + +``` +| Operation | Baseline | Current | Change | +|------------------------------|----------|---------|---------| +| Save_BatchOf100 | 245ms | 198ms | -19.2% | +| GetContentSchedulesByIds | 892ms | 156ms | -82.5% | βœ“ N+1 fixed +| MoveToRecycleBin_LargeTree | 1240ms | 1180ms | -4.8% | +```