umbrella-dotnet-scaffold-file-handler
Scaffold a file handler (interface, implementation, DirectoryNames constant, DI registration) in the Core.Logic project, following the Umbrella UmbrellaFileHandler pattern. Authorization is separate —
SKILL.md.ai-shared/skills/umbrella-dotnet-scaffold-file-handler/SKILL.md
GitHubScaffold File Handler
Purpose
Add a new file handler to the Core.<AppName>.Core.Logic project. File handlers plug into the Umbrella file storage infrastructure and are responsible for storage operations only: saving, retrieving, deleting files, generating web-relative or versioned URLs, caching file lookups, and optional post-save processing (e.g. image resizing).
Authorization is decoupled and lives in a separate UmbrellaFileAuthorizationHandler — see the umbrella-dotnet-scaffold-file-authorization-handler skill. A file handler can exist without a matching authorization handler if access control is handled elsewhere, but be aware that the default storage provider behaviour is to deny access for directories with no registered auth handler.
A file handler can represent any logical grouping of files — files attached to a database record, files in a SharePoint-style folder, user uploads, generated reports, or anything else. The group ID (int) is whatever identifier separates one group of files from another for this particular handler.
How the provider uses the handler
The provider uses the handler's DirectoryName to construct the file path (/<directoryName>/<groupId>/<fileName>). The DirectoryName property on the handler must exactly match the constant registered in DirectoryNames, as authorization lookup (in the separate auth handler) uses the same value.
Discovery (read these before writing anything)
- Read existing file handler implementations in
Core\<AppName>.Core.Logic\FileSystem\to understand the pattern. - Read the interfaces in
Core\<AppName>.Core.Logic\FileSystem\Abstractions\. - Read
Core\<AppName>.Core.Common\FileSystem\Constants\DirectoryNames.csto see existing constants and theAllcollection. - Read
Core\<AppName>.Core.Logic\IServiceCollectionExtensions.csto see where handlers are registered. - Read the consuming project's
TargetFrameworkorTargetFrameworksand its direct package references. Do not infer its target frameworks from Umbrella's multi-targeted source projects. - Confirm the installed
UmbrellaFileHandler<TGroupId>constructor for every consumer target. Current Umbrella packages use MicrosoftHybridCacheon .NET 9 or later andIDistributedCachebelow .NET 9; never use the removed UmbrellaIHybridCacheabstraction.
Step 1 -- Add the DirectoryNames constant
File: Core\<AppName>.Core.Common\FileSystem\Constants\DirectoryNames.cs
Add a new public const string entry using lowercase, hyphenated naming (kebab-case):
public const string <Name> = "<name-in-kebab-case>";
Also add the new constant to the All collection:
public static readonly IReadOnlyCollection<string> All = [
// existing entries ...
<Name>
];
Step 2 -- Create the interface
File location: Core\<AppName>.Core.Logic\FileSystem\Abstractions\I<Name>FileHandler.cs
using Umbrella.FileSystem.Abstractions;
namespace <AppName>.Core.Logic.FileSystem.Abstractions;
public interface I<Name>FileHandler : IUmbrellaFileHandler<int>;
Rules:
- Empty marker interface — all behaviour comes from the base interface and implementation
- Always extends
IUmbrellaFileHandler<int> - Needs the explicit
using Umbrella.FileSystem.Abstractions;— not in global usings for this project
Step 3 -- Create the implementation
File location: Core\<AppName>.Core.Logic\FileSystem\<Name>FileHandler.cs
The primary examples below are for projects whose targets are all .NET 9 or later. Use the target-framework decision rules after the examples for legacy or genuinely cross-boundary multi-targeted consumers.
Minimal pattern (no post-save processing):
using <AppName>.Core.Common.FileSystem.Constants;
using <AppName>.Core.Logic.FileSystem.Abstractions;
using Microsoft.Extensions.Caching.Hybrid;
using Umbrella.FileSystem.Abstractions;
using Umbrella.Utilities.Caching.Abstractions;
namespace <AppName>.Core.Logic.FileSystem;
internal sealed class <Name>FileHandler : UmbrellaFileHandler<int>, I<Name>FileHandler
{
public <Name>FileHandler(
ILogger<<Name>FileHandler> logger,
HybridCache cache,
ICacheKeyUtility cacheKeyUtility,
IUmbrellaFileStorageProvider fileProvider,
IUmbrellaFileStorageProviderOptions options)
: base(logger, cache, cacheKeyUtility, fileProvider, options)
{
}
public override string DirectoryName => DirectoryNames.<Name>;
}
With post-save image resizing:
using <AppName>.Core.Common.FileSystem.Constants;
using <AppName>.Core.Logic.FileSystem.Abstractions;
using Microsoft.Extensions.Caching.Hybrid;
using Umbrella.DynamicImage.Abstractions;
using Umbrella.FileSystem.Abstractions;
using Umbrella.Utilities.Caching.Abstractions;
namespace <AppName>.Core.Logic.FileSystem;
internal sealed class <Name>FileHandler : UmbrellaFileHandler<int>, I<Name>FileHandler
{
private readonly IDynamicImageResizer _dynamicImageResizer;
private readonly IDynamicImageUtility _dynamicImageUtility;
public <Name>FileHandler(
ILogger<<Name>FileHandler> logger,
HybridCache cache,
ICacheKeyUtility cacheKeyUtility,
IUmbrellaFileStorageProvider fileProvider,
IUmbrellaFileStorageProviderOptions options,
IDynamicImageResizer dynamicImageResizer,
IDynamicImageUtility dynamicImageUtility)
: base(logger, cache, cacheKeyUtility, fileProvider, options)
{
_dynamicImageResizer = dynamicImageResizer;
_dynamicImageUtility = dynamicImageUtility;
}
public override string DirectoryName => DirectoryNames.<Name>;
protected override async Task AfterSavingAsync(IUmbrellaFileInfo fileInfo, int groupId, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();
byte[] imageBytes = await fileInfo.ReadAsByteArrayAsync(cancellationToken: cancellationToken);
var format = _dynamicImageUtility.ParseImageFormat(Path.GetExtension(fileInfo.Name));
var (resizedBytes, _, _) = _dynamicImageResizer.ResizeImage(imageBytes, 1600, 1600, DynamicResizeMode.UseWidth, format, DynamicImageFilterQuality.High, 100);
await fileInfo.WriteFromByteArrayAsync(resizedBytes, cancellationToken: cancellationToken);
}
}
Rules:
- Always
internal sealed classinheritingUmbrellaFileHandler<int>and the marker interface - Base 5 constructor params in this exact order:
ILogger<T>, the cache type selected below,ICacheKeyUtility,IUmbrellaFileStorageProvider,IUmbrellaFileStorageProviderOptions— all passed to: base(...) - Choose the cache dependency from the consuming project's targets:
| Consumer targets | Constructor type | Using |
|---|---|---|
| All targets are .NET 9 or later | HybridCache |
using Microsoft.Extensions.Caching.Hybrid; |
| All targets are below .NET 9 | IDistributedCache |
using Microsoft.Extensions.Caching.Distributed; |
| Targets exist on both sides of .NET 9 | A conditional PlatformCache alias |
Alias HybridCache for NET9_0_OR_GREATER; otherwise alias IDistributedCache |
- Do not emit preprocessor directives for a single-target project, or for a multi-target project whose targets all select the same cache API. Umbrella's own multi-targeting is not a reason to copy its conditional alias into consumer code.
- If the consumer directly names
HybridCache, add a directMicrosoft.Extensions.Caching.Hybridpackage reference for each applicable target and keep its major version coupled to that target framework (for example, 9.x fornet9.0and 10.x fornet10.0). - On .NET 9 or later,
AddUmbrellaUtilities()provides the baselineHybridCacheregistration. Do not add a secondAddHybridCache()call merely to activate the handler. Use Microsoft'sAddHybridCache(options => ...)only when the application intentionally configures cache options. - Below .NET 9, verify that the application registers an
IDistributedCacheimplementation. - Extra dependencies go AFTER the 5 base params and are stored as
private readonlyfields DirectoryNamemust returnDirectoryNames.<Name>— the same constant added in Step 1- Do NOT add
AuthorizeAsynchere — authorization belongs in a separateUmbrellaFileAuthorizationHandler(seeumbrella-dotnet-scaffold-file-authorization-handler) - Override
AfterSavingAsynconly when post-save processing is needed; always callcancellationToken.ThrowIfCancellationRequested()first using Umbrella.DynamicImage.Abstractions;is only needed when overridingAfterSavingAsyncfor image work — confirm the package is referenced in the.csproj- Dynamic Image consumers obtain their URL/token pair from the inherited
GetVersionedWebFilePathAsync; do not add a second token algorithm to the concrete handler. - If several concrete handlers share behavior, put it in an
internal abstractbase whose name ends inFileHandlerBase; keep each DI-resolved implementation named*FileHandlerandinternal sealed.
Custom operations and logging
When a handler needs a custom public operation, keep argument guards and cancellation checks before its outer try. The preferred logging pattern is the repository exception filter with explicit method state:
catch (Exception exc) when (Logger.WriteError(exc, new { groupId, fileName, ownerId }))
{
throw new <AppName>CoreLogicException("There has been a problem processing the file.", exc);
}
Use this form instead of calling Logger.LogError or Logger.LogCritical directly. UA008 currently accepts structured calls to those methods, so an analyzer-clean build alone does not prove that the preferred exception-filter pattern was used.
Authorization metadata and temporary files
File handlers are singletons and must remain stateless between calls. When protected-file reads depend on per-request ownership or tenancy metadata:
- Pass the metadata to a narrowly scoped custom handler method as explicit parameters.
- Resize or otherwise prepare the temporary file and write its authorization metadata before calling
CreateByGroupIdAndTempFileNameAsync. This lets the normal move carry a fully secured file into the protected directory. - Do not defer required authorization metadata until a post-move read or
AfterSavingAsyncoperation if that read would need the same metadata to authorize it. - Never use
AsyncLocal,ThreadStatic, mutable singleton fields, ambient context classes, or authorization-bypass flags to transport metadata through the file lifecycle. - Never add a special bypass to the matching file authorization handler for file-handler internals. If the framework lifecycle appears to require one, inspect the base handler lifecycle and storage-provider APIs before proceeding.
- Verify through the real HTTP file endpoint that metadata survives the temporary-to-protected move and that matching and mismatching identities receive the expected responses.
Step 4 -- Register in DI
File: Core\<AppName>.Core.Logic\IServiceCollectionExtensions.cs
Add one line in the // File Handlers section, in alphabetical order:
_ = services.AddSingleton<I<Name>FileHandler, <Name>FileHandler>();
File handlers are always AddSingleton — they are stateless and safe to share across requests.
Analyzer compatibility
Before finishing, read .ai-shared\bundles\umbrella\analyzer-compatibility.md and build the affected projects with their installed analyzers enabled. Treat diagnostics introduced by the generated or changed code as defects in this workflow.
Verification
DirectoryNames.<Name>constant exists and is added to theAllcollection.- The interface is an empty marker extending
IUmbrellaFileHandler<int>with theusing Umbrella.FileSystem.Abstractions;directive. - The implementation is
internal sealed, the constructor passes exactly the 5 base params to: base(...), andDirectoryNamereturns the correct constant. Reusable abstract bases areinternal abstractand end inFileHandlerBase. - There is no
AuthorizeAsyncon the file handler — authorization is in a separate handler. AddSingleton<I<Name>FileHandler, <Name>FileHandler>()is present inIServiceCollectionExtensions.cs.- If authorization is needed, the
umbrella-dotnet-scaffold-file-authorization-handlerskill has been used to create a matching<Name>FileAuthorizationHandlerwith the sameDirectoryName. - If the handler backs Dynamic Image, callers use
GetVersionedWebFilePathAsyncand propagate its URL and token together. - The cache constructor type matches every consuming target: direct
HybridCachefor all-.NET-9-or-later projects, directIDistributedCachefor all-legacy projects, or a conditional alias only for a genuine cross-boundary multi-target project. There is noIHybridCacheusage or legacy-cache warning suppression. - Custom public methods use the
Logger.WriteErrorexception-filter pattern with relevant state. - Ownership or tenancy metadata is attached without ambient state or an authorization bypass, and its behavior is covered through the HTTP file endpoint.