Files
Sharepoint-Toolbox/.planning/research/ARCHITECTURE.md
Kawa 0c2e26e597 docs: complete project research for SharePoint Toolbox rewrite
Research covers stack (NET10/WPF/PnP.Framework), features (v1 parity + v1.x
differentiators), architecture (MVVM four-layer pattern), and pitfalls
(10 critical pitfalls all addressed in foundation phase). SUMMARY.md
synthesizes findings with phase-structured roadmap implications.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-02 10:07:47 +02:00

29 KiB

Architecture Research

Domain: C#/WPF SharePoint Online Administration Desktop Tool Researched: 2026-04-02 Confidence: HIGH

Standard Architecture

System Overview

┌─────────────────────────────────────────────────────────────────────┐
│                         PRESENTATION LAYER                           │
│  ┌──────────────┐  ┌─────────────────────────────────────────────┐  │
│  │   MainWindow │  │             Feature Views (XAML)             │  │
│  │   Shell.xaml │  │  Permissions │ Storage │ Search │ Templates  │  │
│  │              │  │  Duplicates  │ Bulk    │ Reports │ Settings  │  │
│  └──────┬───────┘  └──────────────────────┬────────────────────┘  │
│         │ DataContext binding              │ DataContext binding     │
├─────────┴─────────────────────────────────┴────────────────────────┤
│                          VIEWMODEL LAYER                             │
│  ┌─────────────┐  ┌──────────────────────────────────────────────┐  │
│  │  MainWindow │  │           Feature ViewModels                  │  │
│  │  ViewModel  │  │  PermissionsVM │ StorageVM │ SearchVM         │  │
│  │  (nav/shell)│  │  TemplatesVM   │ BulkOpsVM │ DuplicatesVM     │  │
│  └──────┬──────┘  └───────────────────────┬──────────────────────┘  │
│         │ ICommand, ObservableProperty     │ AsyncRelayCommand        │
├─────────┴─────────────────────────────────┴────────────────────────┤
│                           SERVICE LAYER                              │
│  ┌────────────────┐  ┌─────────────────┐  ┌──────────────────────┐  │
│  │ AuthService    │  │ SharePoint       │  │ Cross-Cutting        │  │
│  │ SessionManager │  │ Feature Services │  │ Services             │  │
│  │ TenantSession  │  │ PermissionsService│ │ ReportExportService  │  │
│  │                │  │ StorageService   │  │ LocalizationService  │  │
│  │                │  │ SearchService    │  │ DialogService        │  │
│  │                │  │ TemplateService  │  │ SettingsService      │  │
│  └────────┬───────┘  └────────┬────────┘  └──────────────────────┘  │
│           │ ClientContext      │ IProgress<T>, CancellationToken      │
├───────────┴────────────────────┴────────────────────────────────────┤
│                    INFRASTRUCTURE / INTEGRATION LAYER                │
│  ┌──────────────────┐  ┌───────────────────┐  ┌──────────────────┐  │
│  │  PnP Framework   │  │ Microsoft Graph    │  │  Local Storage   │  │
│  │  AuthManager     │  │ GraphServiceClient │  │  JSON Files      │  │
│  │  ClientContext   │  │ (Graph operations) │  │  Profiles        │  │
│  │  (CSOM ops)      │  │                   │  │  Templates       │  │
│  └──────────────────┘  └───────────────────┘  └──────────────────┘  │
└─────────────────────────────────────────────────────────────────────┘

Component Responsibilities

Component Responsibility Typical Implementation
MainWindow Shell Tab navigation, tenant selector, app chrome, log panel XAML with TabControl or navigation frame
Feature Views User input forms, result grids, progress indicators UserControl XAML, zero code-behind
Feature ViewModels Commands, observable state, orchestrates services ObservableObject subclass, AsyncRelayCommand
AuthService / SessionManager Multi-tenant session lifecycle, token cache, active tenant state Singleton, MSAL token cache per tenant
TenantSession Per-tenant PnP ClientContext + auth token Immutable record, created by AuthService
SharePoint Feature Services Domain logic that calls PnP Framework or Graph Stateless class, injectable, cancellable
ReportExportService HTML/CSV generation from result models Stateless, template-based string builder
LocalizationService Key-based EN/FR translation, dynamic language switch Singleton, loads lang/*.json, INotifyPropertyChanged
SettingsService Read/write JSON settings, profiles, templates Singleton, file I/O wrapped in async
DialogService Open files, show message boxes, pick folders Interface + WPF implementation, testable

SharepointToolbox/
├── App.xaml                        # Application entry, DI container bootstrap
├── App.xaml.cs                     # Host builder, service registration
│
├── Core/                           # Domain models — no WPF dependencies
│   ├── Models/
│   │   ├── PermissionEntry.cs
│   │   ├── StorageMetrics.cs
│   │   ├── SiteTemplate.cs
│   │   ├── TenantProfile.cs
│   │   └── SearchResult.cs
│   ├── Interfaces/
│   │   ├── IAuthService.cs
│   │   ├── IPermissionsService.cs
│   │   ├── IStorageService.cs
│   │   ├── ISearchService.cs
│   │   ├── ITemplateService.cs
│   │   ├── IBulkOpsService.cs
│   │   ├── IDuplicateService.cs
│   │   ├── IReportExportService.cs
│   │   ├── ISettingsService.cs
│   │   ├── ILocalizationService.cs
│   │   └── IDialogService.cs
│   └── Exceptions/
│       ├── SharePointConnectionException.cs
│       └── AuthenticationException.cs
│
├── Services/                       # Business logic + infrastructure
│   ├── Auth/
│   │   ├── AuthService.cs          # PnP AuthenticationManager wrapper
│   │   ├── SessionManager.cs       # Multi-tenant session store
│   │   └── TenantSession.cs        # Per-tenant PnP ClientContext holder
│   ├── SharePoint/
│   │   ├── PermissionsService.cs   # Recursive permission scanning
│   │   ├── StorageService.cs       # Storage metric traversal
│   │   ├── SearchService.cs        # KQL-based search via PnP/Graph
│   │   ├── TemplateService.cs      # Capture & apply site templates
│   │   ├── DuplicateService.cs     # File/folder duplicate detection
│   │   └── BulkOpsService.cs       # Transfer, site creation, member add
│   ├── Reporting/
│   │   ├── HtmlReportService.cs    # Self-contained HTML + JS reports
│   │   └── CsvExportService.cs     # CSV export
│   ├── LocalizationService.cs      # EN/FR key-value translations
│   ├── SettingsService.cs          # JSON profiles, templates, settings
│   └── DialogService.cs            # WPF dialog abstractions
│
├── ViewModels/                     # WPF-aware but UI-framework-agnostic
│   ├── MainWindowViewModel.cs      # Shell nav, tenant switcher, log
│   ├── Permissions/
│   │   └── PermissionsViewModel.cs
│   ├── Storage/
│   │   └── StorageViewModel.cs
│   ├── Search/
│   │   └── SearchViewModel.cs
│   ├── Templates/
│   │   └── TemplatesViewModel.cs
│   ├── Duplicates/
│   │   └── DuplicatesViewModel.cs
│   ├── BulkOps/
│   │   └── BulkOpsViewModel.cs
│   └── Settings/
│       └── SettingsViewModel.cs
│
├── Views/                          # XAML — no business logic
│   ├── MainWindow.xaml
│   ├── Permissions/
│   │   └── PermissionsView.xaml
│   ├── Storage/
│   │   └── StorageView.xaml
│   ├── Search/
│   │   └── SearchView.xaml
│   ├── Templates/
│   │   └── TemplatesView.xaml
│   ├── Duplicates/
│   │   └── DuplicatesView.xaml
│   ├── BulkOps/
│   │   └── BulkOpsView.xaml
│   └── Settings/
│       └── SettingsView.xaml
│
├── Controls/                       # Reusable WPF controls
│   ├── TenantSelectorControl.xaml
│   ├── LogPanelControl.xaml
│   ├── ProgressOverlayControl.xaml
│   └── StorageChartControl.xaml    # LiveCharts2 wrapper
│
├── Converters/                     # IValueConverter implementations
│   ├── BytesToStringConverter.cs
│   ├── BoolToVisibilityConverter.cs
│   └── PermissionColorConverter.cs
│
├── Resources/                      # Styles, brushes, theme
│   ├── Styles.xaml
│   └── Colors.xaml
│
├── Lang/                           # Language files
│   ├── en.json
│   └── fr.json
│
└── Infrastructure/
    └── Behaviors/                  # XAML attached behaviors (no code-behind workaround)
        └── ScrollToBottomBehavior.cs

Structure Rationale

  • Core/: Pure C# — no WPF references. Interfaces here make services testable. Models are plain data classes.
  • Services/: All domain logic and I/O. Injected via constructor DI. No static state.
  • ViewModels/: Mirror the feature structure. Depend on service interfaces, never on concrete implementations.
  • Views/: XAML-only. No logic. DataContext set by DI or ViewModelLocator pattern at startup.
  • Controls/: Reusable UI widgets that encapsulate chart, log, and progress concerns.

Architectural Patterns

Pattern 1: ObservableObject + AsyncRelayCommand (CommunityToolkit.Mvvm)

What: Use ObservableObject as base class for all ViewModels. Use [ObservableProperty] source-gen attribute for bindable properties. Use AsyncRelayCommand (with CancellationToken) for all SharePoint operations.

When to use: All ViewModels. This is the standard pattern for .NET 8 + WPF.

Trade-offs: Source generators require C# 10+. Generated partial class syntax is unfamiliar at first but eliminates 80% of boilerplate.

Example:

public partial class PermissionsViewModel : ObservableObject
{
    private readonly IPermissionsService _permissionsService;

    [ObservableProperty]
    private bool _isRunning;

    [ObservableProperty]
    private string _statusMessage = string.Empty;

    [ObservableProperty]
    private ObservableCollection<PermissionEntry> _results = new();

    public IAsyncRelayCommand RunReportCommand { get; }

    public PermissionsViewModel(IPermissionsService permissionsService)
    {
        _permissionsService = permissionsService;
        RunReportCommand = new AsyncRelayCommand(RunReportAsync, allowConcurrentExecutions: false);
    }

    private async Task RunReportAsync(CancellationToken cancellationToken)
    {
        IsRunning = true;
        StatusMessage = "Scanning permissions...";
        try
        {
            var results = await _permissionsService.ScanAsync(
                SiteUrl, cancellationToken,
                new Progress<string>(msg => StatusMessage = msg));
            Results = new ObservableCollection<PermissionEntry>(results);
        }
        finally { IsRunning = false; }
    }
}

Pattern 2: Multi-Tenant Session Manager

What: A singleton SessionManager holds a dictionary of TenantSession objects keyed by tenant URL. When the user selects a tenant profile, the session is reused if still valid (MSAL token cache handles token refresh). No re-authentication unless the token is expired and silent refresh fails.

When to use: Every SharePoint service operation resolves IAuthService.GetSessionAsync(tenantUrl) before calling PnP Framework.

Trade-offs: MSAL token cache must be persisted across app restarts for seamless reconnect. For interactive login, MSAL PublicClientApplicationBuilder with WithParentActivityOrWindow is required on Windows to avoid a blank browser window.

Example:

public class SessionManager
{
    private readonly ConcurrentDictionary<string, TenantSession> _sessions = new();

    public async Task<TenantSession> GetOrCreateSessionAsync(
        TenantProfile profile, CancellationToken ct)
    {
        if (_sessions.TryGetValue(profile.TenantUrl, out var session)
            && !session.IsExpired)
            return session;

        var authManager = new PnP.Framework.AuthenticationManager(
            profile.ClientId,
            openBrowserCallback: url => Process.Start(new ProcessStartInfo(url) { UseShellExecute = true }));

        var ctx = await authManager.GetContextAsync(profile.TenantUrl);
        var newSession = new TenantSession(profile, ctx, authManager);
        _sessions[profile.TenantUrl] = newSession;
        return newSession;
    }
}

Pattern 3: IProgress<T> + CancellationToken for All Long Operations

What: Every service method that calls SharePoint accepts IProgress<OperationProgress> and CancellationToken. The ViewModel creates Progress<T> (which marshals callbacks to the UI thread automatically) and CancellationTokenSource.

When to use: All SharePoint service methods. This replaces the PowerShell runspace + timer polling pattern from the existing app.

Trade-offs: Progress<T> captures SynchronizationContext on creation — must be created on the UI thread (i.e., inside the ViewModel, not inside the service).

Example:

// In ViewModel (UI thread context):
var cts = new CancellationTokenSource();
CancelCommand = new RelayCommand(() => cts.Cancel());
var progress = new Progress<OperationProgress>(p => StatusMessage = p.Message);

// In Service (any thread):
public async Task<IList<PermissionEntry>> ScanAsync(
    string siteUrl,
    CancellationToken ct,
    IProgress<OperationProgress> progress)
{
    progress.Report(new OperationProgress("Connecting..."));
    using var ctx = await _sessionManager.GetOrCreateSessionAsync(..., ct);
    // ... recursive scanning ...
    ct.ThrowIfCancellationRequested();
    progress.Report(new OperationProgress($"Found {results.Count} entries"));
    return results;
}

Pattern 4: Messenger for Cross-ViewModel Events

What: Use CommunityToolkit.Mvvm.Messaging.WeakReferenceMessenger for decoupled communication between ViewModels (e.g., "tenant switched" notifies all feature VMs to reset state, "log entry added" updates the log panel ViewModel).

When to use: When two ViewModels need to communicate without direct reference (shell ↔ feature VMs, service callbacks ↔ log panel).

Trade-offs: Weak references mean recipients must be alive (held by DI container). Don't use for per-request data passing — use method return values for that.

Pattern 5: Dependency Injection via Microsoft.Extensions.Hosting

What: Bootstrap the app with Host.CreateDefaultBuilder() in App.xaml.cs. Register all services, ViewModels, and the main window in the DI container. Use constructor injection everywhere — no service locator anti-pattern.

Example:

// App.xaml.cs
protected override void OnStartup(StartupEventArgs e)
{
    _host = Host.CreateDefaultBuilder()
        .ConfigureServices(services =>
        {
            // Core services (singletons)
            services.AddSingleton<ISettingsService, SettingsService>();
            services.AddSingleton<ILocalizationService, LocalizationService>();
            services.AddSingleton<SessionManager>();
            services.AddSingleton<IAuthService, AuthService>();
            services.AddSingleton<IDialogService, DialogService>();

            // Feature services (transient — no shared state)
            services.AddTransient<IPermissionsService, PermissionsService>();
            services.AddTransient<IStorageService, StorageService>();
            services.AddTransient<ISearchService, SearchService>();

            // ViewModels
            services.AddTransient<MainWindowViewModel>();
            services.AddTransient<PermissionsViewModel>();
            services.AddTransient<StorageViewModel>();

            // Views
            services.AddSingleton<MainWindow>();
        })
        .Build();

    _host.Start();
    var mainWindow = _host.Services.GetRequiredService<MainWindow>();
    mainWindow.DataContext = _host.Services.GetRequiredService<MainWindowViewModel>();
    mainWindow.Show();
}

Data Flow

SharePoint Operation Request Flow

User clicks "Run" button
        ↓
View command binding triggers AsyncRelayCommand.ExecuteAsync()
        ↓
ViewModel validates inputs → creates CancellationTokenSource + Progress<T>
        ↓
ViewModel calls IFeatureService.ScanAsync(params, ct, progress)
        ↓
Service calls SessionManager.GetOrCreateSessionAsync(profile, ct)
        ↓
SessionManager checks cache → reuses token or triggers interactive login
        ↓
Service executes PnP Framework / Graph SDK calls (async, awaited)
        ↓
Service reports incremental progress → Progress<T>.Report() → UI thread
        ↓
Service returns result collection to ViewModel
        ↓
ViewModel updates ObservableCollection → WPF binding refreshes DataGrid
        ↓
ViewModel sets IsRunning = false → progress overlay hides

Authentication & Session Flow

User selects tenant profile from dropdown
        ↓
MainWindowViewModel calls SessionManager.SetActiveProfile(profile)
        ↓
SessionManager publishes TenantChangedMessage via WeakReferenceMessenger
        ↓
All feature ViewModels receive message → reset their state/results
        ↓
On first operation: SessionManager.GetOrCreateSessionAsync()
        ↓
   [Cache hit: token valid] → return existing ClientContext immediately
   [Cache miss / expired]   → PnP AuthManager.GetContextAsync()
                                    ↓
                               MSAL silent token refresh attempt
                                    ↓
                          [Silent fails] → open browser for interactive login
                                    ↓
                          User authenticates → token cached by MSAL
                                    ↓
                          ClientContext returned to caller

Report Export Flow

Service returns List<TModel> to ViewModel
        ↓
User clicks "Export CSV" or "Export HTML"
        ↓
ViewModel calls IReportExportService.ExportAsync(results, format, outputPath)
        ↓
ReportExportService generates file (string building, no blocking I/O on UI thread)
        ↓
ViewModel calls IDialogService.OpenFile(outputPath) to auto-open result

State Management

AppState (DI-managed singletons):
  SessionManager     → active profile, tenant sessions dict
  SettingsService    → user prefs, data folder, profiles list
  LocalizationService → current language, translation dict

Per-Operation State (ViewModel-local):
  ObservableCollection<T>  → bound to DataGrid
  CancellationTokenSource  → cancel button binding
  IsRunning (bool)         → progress overlay binding
  StatusMessage (string)   → progress label binding

Component Boundaries

What Communicates With What

Boundary Communication Method Direction Notes
View ↔ ViewModel WPF data binding (two-way for inputs, one-way for results) Both No code-behind
ViewModel ↔ Service Constructor-injected interface, async method call VM → Service Services return Task<T>
ViewModel ↔ ViewModel WeakReferenceMessenger messages Broadcast Tenant switch, log events
Service ↔ SessionManager GetOrCreateSessionAsync() Service → SessionMgr Every SharePoint call
SessionManager ↔ PnP Framework AuthenticationManager.GetContextAsync() SessionMgr → PnP On cache miss only
Service ↔ Graph SDK GraphServiceClient method calls Service → Graph For Graph-only operations
SettingsService ↔ FileSystem System.Text.Json + File.ReadAllText/WriteAllText Both Async I/O
LocalizationService ↔ Views XAML binding to translated string properties Service → View Via singleton binding

What Must NOT Cross Boundaries

  • Views must not call services directly — all via ViewModel commands
  • Services must not reference any WPF types (System.Windows.*) — use IProgress<T> for UI feedback
  • ViewModels must not instantiate ClientContext or AuthenticationManager directly — only via IAuthService
  • SessionManager is the only class that holds ClientContext objects — services receive them per-operation

Build Order (Dependency Graph)

The following reflects the order components can be built because later items depend on earlier ones:

Phase 1: Foundation
  └── Core/Models/* (no dependencies)
  └── Core/Interfaces/* (no dependencies)
  └── Core/Exceptions/* (no dependencies)

Phase 2: Infrastructure Services
  └── SettingsService (depends on Core models)
  └── LocalizationService (depends on lang files)
  └── DialogService (depends on WPF — implement last in phase)
  └── AuthService / SessionManager (depends on PnP Framework NuGet)

Phase 3: Feature Services (depend on Auth + Core)
  └── PermissionsService
  └── StorageService
  └── SearchService
  └── TemplateService
  └── DuplicateService
  └── BulkOpsService

Phase 4: Reporting (depends on Feature Services output models)
  └── HtmlReportService
  └── CsvExportService

Phase 5: ViewModels (depend on service interfaces)
  └── MainWindowViewModel (shell, nav, tenant selector)
  └── Feature ViewModels (Permissions, Storage, Search, Templates, Duplicates, BulkOps)
  └── SettingsViewModel

Phase 6: Views + App Bootstrap (depend on ViewModels + DI)
  └── XAML Views (bind to ViewModels)
  └── Controls (TenantSelector, LogPanel, Charts)
  └── App.xaml.cs DI container wiring

Scaling Considerations

This is a local desktop tool with a single user. "Scaling" means handling larger SharePoint tenants, not more users.

Concern Approach
Large site collections (1000+ sites) Async streaming with early cancellation; paginated PnP calls; virtual DataGrid
Deep permission hierarchies Configurable scan depth; user can limit scope to top-level only
Large file search results Server-side KQL filtering first, client-side regex only as secondary pass
Multiple simultaneous operations Each ViewModel has its own CancellationTokenSource; operations are isolated
Session token expiry during long scan MSAL silent refresh + retry on 401; surface error to user if re-auth needed

Anti-Patterns

Anti-Pattern 1: Dispatcher.Invoke in Services

What people do: Call Application.Current.Dispatcher.Invoke() inside service classes to update UI state. Why it's wrong: Couples service layer to WPF, makes services untestable, causes deadlocks if called from wrong thread. Do this instead: Service accepts IProgress<T> parameter. Progress<T> marshals to UI thread automatically via the captured SynchronizationContext.

Anti-Pattern 2: Giant "God ViewModel"

What people do: Create one MainViewModel with all feature logic, mirroring the monolithic PowerShell script. Why it's wrong: Replicates the exact problem being solved. Hard to navigate, hard to test, merge conflicts on every change. Do this instead: One ViewModel per feature tab. MainWindowViewModel owns only shell navigation, active tenant, and log state.

Anti-Pattern 3: Storing ClientContext as a Long-Lived Static

What people do: Cache ClientContext in a static field for reuse. Why it's wrong: ClientContext is not thread-safe and has an auth token that expires. Static makes it impossible to manage per-tenant. Do this instead: SessionManager manages ClientContext lifetime. Services request a context per operation. PnP Framework handles token refresh.

Anti-Pattern 4: Blocking Async on Sync Context

What people do: Call .Result or .Wait() on Tasks inside WPF event handlers to avoid async void. Why it's wrong: Deadlocks the WPF SynchronizationContext. The UI freezes permanently. Do this instead: Use async void only for top-level event handlers (acceptable in WPF), or bind all user actions to AsyncRelayCommand.

Anti-Pattern 5: Silent Catch Blocks (porting the existing bug)

What people do: Wrap PnP calls in catch {} or catch { /* ignore */ } to prevent crashes. Why it's wrong: The existing PowerShell app has 38 such blocks — they produce silent failures, missing data, and phantom "success" states. Do this instead: Catch specific exceptions (SharePointException, MicrosoftIdentityException). Log with full stack trace via ILogger. Surface user-visible error message via ViewModel's ErrorMessage property.


Integration Points

External Services

Service Integration Pattern Library Notes
SharePoint Online (CSOM) PnP Framework ClientContext PnP.Framework NuGet Use for permissions, storage, templates, bulk ops
SharePoint Search PnP Framework SearchRequest PnP.Framework NuGet KQL queries; paginated
Microsoft Graph GraphServiceClient Microsoft.Graph NuGet Use for user/group lookups, Teams data
Azure AD / MSAL PublicClientApplication via PnP AuthenticationManager Built into PnP.Framework Interactive browser login; token cache callback
WPF Charts LiveCharts2 or OxyPlot.Wpf NuGet Storage metrics visualization; LiveCharts2 preferred for richer WPF binding

Internal Boundaries

Boundary Communication Notes
SessionManager ↔ Feature Services TenantSession passed per operation Services do not store sessions
LocalizationService ↔ XAML Singleton bound via StaticResource; properties fire INotifyPropertyChanged on language switch All UI text goes through this
ReportExportService ↔ ViewModels Called after operation completes; returns file path Self-contained HTML with embedded JS/CSS
SettingsService ↔ all singletons Read at startup; written on change JSON format must match existing Sharepoint_Settings.json schema for migration

Sources


Architecture research for: C#/WPF SharePoint Online administration desktop tool Researched: 2026-04-02