Skip to main content

Files Module Integration

The PearDrop Files module provides file storage abstraction, access control, and queryable read models for managing uploaded files and documents across your application.

Overview

The Files module manages:

  • File uploads: Store files with metadata (name, size, upload date, kind)
  • File access: Permission-based access control (who can read/delete files)
  • File retrieval: Query files by owner, kind, or custom criteria
  • Storage abstraction: Works with Azure Blob Storage, local filesystem, or custom providers

Key patterns:

  • Read models expose IFilesReadModels interface (no DbContext leakage)
  • File access protected by authorization requirements
  • Keyed DI for multiple file access types within same application
  • Radzen UI components for upload/download workflows

Server-Side Registration

IFilesReadModels Contract

The Files module exposes a read-only interface for querying files:

public interface IFilesReadModels : IModuleReadModels
{
/// <summary>
/// Get queryable access to all files.
/// Authority filtering applied automatically.
/// </summary>
IReadModelQueryable<FileProjection> Files { get; }

/// <summary>
/// Get a single file by ID.
/// Returns null if file not found or user lacks access.
/// </summary>
Task<FileProjection?> GetFileByIdAsync(
Guid fileId,
CancellationToken cancellationToken);

/// <summary>
/// Get all files uploaded by a specific user.
/// </summary>
Task<IEnumerable<FileProjection>> GetFilesByOwnerAsync(
Guid ownerId,
CancellationToken cancellationToken);

/// <summary>
/// Get files filtered by type/kind.
/// </summary>
Task<IEnumerable<FileProjection>> GetFilesByKindAsync(
string kind,
CancellationToken cancellationToken);
}

Injecting Files Read Models:

public sealed class GetUserFilesQueryHandler(
IFilesReadModels filesReadModels,
IHttpContextAccessor httpContextAccessor)
{
public async Task<QueryResult<IEnumerable<FileDto>>> Handle(
GetUserFilesQuery request,
CancellationToken ct)
{
var userId = httpContextAccessor.HttpContext?
.User.FindFirst(ClaimTypes.NameIdentifier)?
.Value;

if (string.IsNullOrEmpty(userId) || !Guid.TryParse(userId, out var parsedUserId))
{
return QueryResult<IEnumerable<FileDto>>.Failed("User not authenticated");
}

// Get all files uploaded by current user
var files = await filesReadModels.GetFilesByOwnerAsync(parsedUserId, ct);

var fileDtos = files.Select(f => new FileDto(
f.Id,
f.Name,
f.Size,
f.Kind,
f.UploadedAt
)).ToList();

return QueryResult<IEnumerable<FileDto>>.Succeeded(fileDtos);
}
}

File Upload Authorization

UploadFileCommandAuthorizer

Protect file upload operations:

public sealed class UploadFileCommandAuthorizer : 
AbstractIgnorableRequestAuthorizer<UploadFileCommand>
{
public override IEnumerable<IAuthorizationRequirement> GetRequirements(
UploadFileCommand request)
{
return new[]
{
new MustBeAuthenticatedRequirement(),
new MustBeActiveUserRequirement()
};
}
}

Command definition:

public sealed record UploadFileCommand(
string FileName,
long FileSize,
Stream FileStream,
string? Kind = null) : ICommand;

File Size Validation

public sealed class UploadFileCommandValidator : 
AbstractValidator<UploadFileCommand>
{
private readonly IFileConstants fileConstants;

public UploadFileCommandValidator(IFileConstants fileConstants)
{
this.fileConstants = fileConstants;

RuleFor(x => x.FileName)
.NotEmpty().MaximumLength(255)
.Matches(@"^[a-zA-Z0-9\-_.()]+$",
RegexOptions.Compiled,
"File name contains invalid characters");

RuleFor(x => x.FileSize)
.LessThanOrEqualTo(fileConstants.MaxUploadSizeInBytes)
.WithMessage($"File exceeds maximum size of {fileConstants.MaxFileNameInMB}MB");

RuleFor(x => x.Kind)
.Must(k => k == null || fileConstants.AllowedKinds.Contains(k))
.WithMessage("File kind is not allowed");
}
}

File Access Control

DeleteFileCommandAuthorizer

Ensure only file owner (or admin) can delete:

public sealed class DeleteFileCommandAuthorizer : 
AbstractIgnorableRequestAuthorizer<DeleteFileCommand>
{
private readonly IFilesReadModels filesReadModels;

public DeleteFileCommandAuthorizer(IFilesReadModels filesReadModels)
{
this.filesReadModels = filesReadModels;
}

public override async Task<AuthorizationResult> AuthorizeAsync(
DeleteFileCommand request,
ClaimsPrincipal? user,
IAuthorizationService authService,
CancellationToken cancellationToken)
{
var userId = user.GetUserId();

// Find the file
var file = await this.filesReadModels.GetFileByIdAsync(
request.FileId,
cancellationToken);

if (file == null)
{
// Return Failed (not Success) — don't leak that file doesn't exist
return AuthorizationResult.Failed();
}

// Only owner can delete (or admin in the handler)
return file.OwnerId == userId
? AuthorizationResult.Success()
: AuthorizationResult.Failed();
}
}

File Storage Abstraction

IFileAccessor Pattern

Files module abstracts storage providers:

public interface IFileAccessor
{
/// <summary>
/// Upload a file stream to storage.
/// Returns file storage path/reference.
/// </summary>
Task<string> UploadAsync(
Stream fileStream,
string fileName,
string? contentType = null,
CancellationToken cancellationToken = default);

/// <summary>
/// Download a file stream from storage.
/// </summary>
Task<Stream> DownloadAsync(
string fileReference,
CancellationToken cancellationToken = default);

/// <summary>
/// Delete a file from storage.
/// </summary>
Task DeleteAsync(
string fileReference,
CancellationToken cancellationToken = default);

/// <summary>
/// Check if a file exists in storage.
/// </summary>
Task<bool> ExistsAsync(
string fileReference,
CancellationToken cancellationToken = default);
}

Keyed DI for Multiple File Types

For applications managing different file categories:

Registration:

services.AddKeyedScoped<IFileAccessor, AzureBlobFileAccessor>(
"documents");

services.AddKeyedScoped<IFileAccessor, LocalFileSystemAccessor>(
"temp");

Usage in handler:

public sealed class UploadDocumentCommandHandler(
[FromKeyedServices("documents")] IFileAccessor documentAccessor)
{
public async Task<CommandResult<UploadDocumentCommandResult>> Handle(
UploadDocumentCommand request,
CancellationToken ct)
{
var fileReference = await documentAccessor.UploadAsync(
request.FileStream,
request.FileName,
"application/pdf",
ct);

// ... store FileProjection with reference
return CommandResult<UploadDocumentCommandResult>.Succeeded(...);
}
}

Querying Files with Read Models

Get All Files by Kind

public sealed class GetReportFilesQueryHandler(
IFilesReadModels filesReadModels)
{
public async Task<QueryResult<IEnumerable<ReportFileDto>>> Handle(
GetReportFilesQuery request,
CancellationToken ct)
{
var files = await filesReadModels.GetFilesByKindAsync(
"report",
ct);

var dtos = files.Select(f => new ReportFileDto(
f.Id,
f.Name,
f.Size,
f.UploadedAt
)).ToList();

return QueryResult<IEnumerable<ReportFileDto>>.Succeeded(dtos);
}
}

Filter with LINQ

public sealed class GetRecentFilesQueryHandler(
IFilesReadModels filesReadModels)
{
public async Task<QueryResult<IEnumerable<FileDto>>> Handle(
GetRecentFilesQuery request,
CancellationToken ct)
{
var thirtyDaysAgo = DateTime.UtcNow.AddDays(-30);

var files = await filesReadModels.Files
.Where(f => f.UploadedAt >= thirtyDaysAgo)
.OrderByDescending(f => f.UploadedAt)
.Take(50)
.Select(f => new FileDto(f.Id, f.Name, f.Size))
.ToListAsync(ct);

return QueryResult<IEnumerable<FileDto>>.Succeeded(files);
}
}

Blazor File Upload Components

FileUploadComponent Pattern

@* Components/FileUpload.razor *@
@implements IAsyncDisposable
@inject ICommandRunner CommandRunner
@inject IToastService ToastService

<div class="file-upload-container">
<InputFile OnChange="HandleFileSelected" />
@if (IsUploading)
{
<div class="progress">
<div class="progress-bar" style="width: @UploadProgress%">
@UploadProgress%
</div>
</div>
}
</div>

@code {
[Parameter]
public string? Kind { get; set; }

[Parameter]
public EventCallback<UploadSuccessDto> OnUploadSuccess { get; set; }

private bool IsUploading { get; set; }
private int UploadProgress { get; set; }

private async Task HandleFileSelected(InputFileChangeEventArgs e)
{
var file = e.File;

if (file.Size > 10 * 1024 * 1024) // 10MB
{
await this.ToastService.Error("File exceeds 10MB limit");
return;
}

this.IsUploading = true;

try
{
using var stream = file.OpenReadStream(maxAllowedSize: 10 * 1024 * 1024);
var command = new UploadFileCommand(file.Name, file.Size, stream, this.Kind);

var result = await this.CommandRunner.ExecuteAsync(command);

if (result.IsSuccess)
{
await this.OnUploadSuccess.InvokeAsync(
new UploadSuccessDto(result.Data.FileId, file.Name));

await this.ToastService.Success("File uploaded successfully");
}
else
{
await this.ToastService.Error("Upload failed: " + result.ErrorMessage);
}
}
finally
{
this.IsUploading = false;
}
}

async ValueTask IAsyncDisposable.DisposeAsync()
{
// Cleanup
}
}

File List Component

@* Components/FileList.razor *@
@inject IQueryRunner QueryRunner

<div class="file-list">
@foreach (var file in Files)
{
<div class="file-item">
<span>@file.Name</span>
<small>@FormatBytes(file.Size) • @file.UploadedAt.ToShortDateString()</small>
<button @onclick="() => DownloadFile(file.Id)" class="btn btn-sm btn-primary">
Download
</button>
<button @onclick="() => DeleteFile(file.Id)" class="btn btn-sm btn-danger">
Delete
</button>
</div>
}
</div>

@code {
private List<FileDto> Files { get; set; } = new();

protected override async Task OnInitializedAsync()
{
var result = await this.QueryRunner.ExecuteAsync(new GetMyFilesQuery());
if (result.IsSuccess)
{
this.Files = result.Data.ToList();
}
}

private async Task DownloadFile(Guid fileId)
{
// Implement download logic
}

private async Task DeleteFile(Guid fileId)
{
var result = await this.CommandRunner.ExecuteAsync(
new DeleteFileCommand(fileId));

if (result.IsSuccess)
{
this.Files.RemoveAll(f => f.Id == fileId);
await this.InvokeAsync(this.StateHasChanged);
}
}

private string FormatBytes(long bytes)
{
string[] sizes = { "B", "KB", "MB", "GB" };
double len = bytes;
int order = 0;

while (len >= 1024 && order < sizes.Length - 1)
{
order++;
len = len / 1024;
}

return $"{len:0.##} {sizes[order]}";
}
}

Integration Checklist

When using Files module in your application:

  • Register Files module: builder.Services.AddPearDropFiles(config)
  • Register Files read models: builder.Services.AddFilesReadModels()
  • Configure file storage provider (Azure, local, custom)
  • Create file upload command handler
  • Create UploadFileCommandAuthorizer (auth + active user)
  • Create DeleteFileCommandAuthorizer (owner check + auth)
  • Implement file queries using IFilesReadModels
  • Create Blazor file upload/download components
  • Set file upload size limits in appsettings.json
  • Set allowed file kinds/extensions in module config
  • Add file cleanup/retention policies if needed

Common Patterns

Profile Picture Upload with Cleanup

public sealed class UpdateProfilePictureCommandHandler(
[FromKeyedServices("profilePics")] IFileAccessor fileAccessor,
IRepositoryFactory<User> repositoryFactory,
IFilesReadModels filesReadModels)
{
protected override async Task<CommandResult<UpdateProfilePictureCommandResult>>
HandleInternalWithRepository(
UpdateProfilePictureCommand request,
CancellationToken ct)
{
var user = await this.Repository.FindOne(
new ByIdSpecification<User>(request.UserId), ct);

if (user.HasNoValue)
{
return CommandResult<UpdateProfilePictureCommandResult>.Failed(
ErrorCodes.NotFound, "User not found");
}

// Delete old picture if exists
if (user.Value.ProfilePictureReference != null)
{
await fileAccessor.DeleteAsync(user.Value.ProfilePictureReference, ct);
}

// Upload new picture
var newImageReference = await fileAccessor.UploadAsync(
request.ImageStream,
$"{request.UserId}_profile.jpg",
"image/jpeg",
ct);

// Update user
user.Value.SetProfilePicture(newImageReference);
this.Repository.Update(user.Value);

var result = await this.Repository.UnitOfWork.SaveEntitiesAsync(ct);
return result.IsSuccess
? CommandResult<UpdateProfilePictureCommandResult>.Succeeded(
new UpdateProfilePictureCommandResult(newImageReference))
: CommandResult<UpdateProfilePictureCommandResult>.Failed(result.Error!);
}
}