Local files or Azure Blob Storage? Choosing a provider with a storage factory

How FileStore selects local disk or Azure Blob Storage for each API client, with code for the shared contract, upload, download, and the boundaries to watch.

One FileStore API with local file storage and Azure Blob Storage options.
Choose a storage provider for each API client.

FileStore started because generated files were scattered across a client's projects. Once I had a dedicated API, the next question was where that API should put the files.

The answer did not have to be the same for every project. One client configuration could point to a local directory; another could use Azure Blob Storage.

I wanted the upload and download handlers to call the same interface in either case. A small storage factory made that possible.

This is the second article in the FileStore series. The examples come from the historical .NET Core 3.1 repository, with comments about the parts I would revisit today. They explain the design, not a complete modern storage service.

Start with the operations the application needs

The application layer defines IStorageService:

public interface IStorageService
{
    Task<FileResponse> StoreFileAsync(ApiClient apiClient, IFormFile file);
    Task<FileResponse> StoreFileAsync(ApiClient apiClient, FileModel file);
    Task<bool> DeleteFileAsync(Guid fileReference, Guid apiClientId);
    Task<FileModel> DownloadAsync(Guid apiClientId, Guid reference);
}

There are two upload forms: an HTTP upload through IFormFile, and a model containing file bytes. The other operations work with the client's ID and a file reference.

The handlers do not need a separate Azure branch. They ask for a storage service and call this contract.

ApiClient configuration selects FileStorageService or AzureBlobStorageService through StorageFactory; both implement IStorageService.

Choose a provider from the client configuration

Each ApiClient has a StorageType. This is the factory from the repository:

public IStorageService GetStorageService(ApiClient client)
{
    switch (client.StorageType)
    {
        case ClientStorageType.File:
            return (IStorageService)serviceProvider.GetService(
                typeof(FileStorageService));

        case ClientStorageType.AzureBlobStorage:
            return (IStorageService)serviceProvider.GetService(
                typeof(AzureBlobStorageService));

        default:
            throw new ConflictException("Uknown storage type");
    }
}

The factory keeps provider selection in one place. AWS appears in the enum, but this factory has no AWS implementation. I would not describe that enum value as a supported provider.

The original factory resolves services through IServiceProvider. In a revision of the design, I would consider injecting the supported providers directly, or at least requiring resolution to succeed explicitly. The useful part is the single selection point, not a requirement to use a service locator.

The handler stays small

After resolving the authenticated client, the upload handler delegates the operation:

var apiClientId = Guid.Parse(currentUserService.ApiClientId);
var apiClient = await apiClientRepository.GetByIdAsync(apiClientId);

return await storageFactory
    .GetStorageService(apiClient)
    .StoreFileAsync(apiClient, request.File);

This code does not know about directories, containers or blob clients. Those details stay inside the chosen implementation.

Local storage: bytes on disk, location in SQL

The local provider creates a generated physical filename under the client's configured base path:

private Domain.Entities.File SaveFileToDisk(
    byte[] fileBytes,
    Domain.Entities.File file,
    ApiClient apiClient)
{
    var fullPath = apiClient.BasePath + '\\' + Guid.NewGuid();
    System.IO.File.WriteAllBytes(fullPath, fileBytes);
    file.FullPath = fullPath;

    return file;
}

The original filename stays in metadata. It is not used as the physical filename in this method.

This is a Windows-oriented path expression. I would replace the string concatenation with a carefully validated configured root and proper path handling, and check directory provisioning and permissions explicitly.

There is another practical detail in the HTTP upload overload: it creates a temporary file, copies the upload to it, then reads those bytes into memory. The temporary file is not deleted in that path. I would simplify that flow and put cleanup in a finally block where a temporary file is actually needed.

The current local download reads all bytes:

var fileBytes = await System.IO.File.ReadAllBytesAsync(file.FullPath);

return new FileModel
{
    FileBytes = fileBytes,
    FileName = file.FileName,
    ContentType = fileType.ContentType,
    Reference = file.Reference,
    DateCreated = file.DateCreated
};

That is easy to follow, but the memory cost grows with file size and concurrent downloads. Streaming would be one of the first things I would consider for larger files.

Azure Blob Storage: same reference, different physical storage

The Azure provider builds a metadata record and uses its reference as the blob name. This is an extract from the IFormFile upload path:

var container = await GetContainer(
    apiClient.StorageSettings.Deserialize<AzureBlobSettings>());

BlobClient blob = container.GetBlobClient(
    fileEntity.Reference.ToString());

var response = await blob.UploadAsync(file.OpenReadStream());

fileEntity.FullPath = blob.Uri.AbsoluteUri;
await fileRepository.AddAsync(fileEntity);

Before this excerpt, the provider checks for an empty upload, looks up the client's allowed content type and creates fileEntity. Those checks are useful, but trusting the submitted MIME type is not a complete file-content validation policy.

The interface stays the same, while FullPath now contains a blob URI rather than a local path.

The download path performs the matching operation:

var container = await GetContainer(blobSettings);
BlobClient blob = container.GetBlobClient(
    fileEntity.Reference.ToString());

Stream blobStream = new MemoryStream();
await blob.DownloadToAsync(blobStream);

var fileBytes = FileStorageService.ReadFully(blobStream);

Again, the current implementation buffers the file. I would add explicit disposal and cancellation, and use a streaming response where the requirements justify it.

Provider selection is not file migration

Changing ApiClient.StorageType tells the factory which service to use next. It does not copy existing files from disk to Azure.

That matters because downloads choose a provider from the client's current configuration. If I change the provider before moving and verifying the existing files, their metadata can remain in SQL while their bytes are still in the old location.

A migration would need a separate plan: identify the affected records, copy the content, verify it, switch the mapping or routing, and retain a recovery path. Supporting two providers does not make that process automatic.

Keep private files private

One line in the existing Azure helper deserves a clear note:

await container.CreateIfNotExistsAsync(
    Azure.Storage.Blobs.Models.PublicAccessType.BlobContainer);

This asks for public container access when creating a container. It is historical behavior in this repository, not a setting I would recommend for private client documents. Account policies can also affect whether public access is permitted.

For private content, I would provision private storage and deliver files through an authorized API or a narrowly scoped temporary access mechanism. The choice depends on the application's access rules. Azure's anonymous access documentation explains the settings involved.

Storage and metadata can still disagree

Both providers write content and then save metadata. SQL Server and the filesystem or blob service are separate resources.

If the content write succeeds and the metadata insert fails, there can be an orphaned file. If deletion succeeds in storage and the row deletion fails, the database can still describe a file that is no longer there.

I would add an explicit recovery approach rather than pretending that the interface removes those failure modes. It might include pending states, retries and reconciliation, depending on how important the files are and how the caller handles failures.

The contract also deserves another pass: Task<bool> gives very little detail about deletion failures. A missing file, an unavailable storage account and a permissions failure should not all be indistinguishable to operations and logs.

What the factory actually buys me

The factory makes the application code easier to follow: resolve the client, select its provider, perform the storage operation.

It also gives each provider a clear place for its own SDK calls and path rules. That is the part I would keep. Streaming, validation, private access and recovery still have to be designed deliberately around it.

Explore FileStore on GitHub, including the factory, local provider, and Azure provider.

Bujar Ademi

Written by

Bujar Ademi

Senior .NET Developer & Team Lead

← All articles