← All articles

Test on disk, ship to Azure: swapping storage backends without touching your code

A quick tip on keeping file storage behind one interface so local development, tests and production can each pick their own backend.
Test on disk, ship to Azure: swapping storage backends without touching your code

Here's a smell worth grepping your solution for: BlobServiceClient (or System.IO.File) referenced from application code. Not because either is bad — but because whichever one you called, you've just decided where files live forever, in every environment, including your unit tests.

File storage is a textbook seam. Put one interface in front of it and the decision moves to configuration:

public interface IFileService
{
    Task<bool>    Exists(string identifier);
    Task<Stream?> GetStream(string identifier);
    Task<string>  Save(string identifier, Stream stream, string? contentType = null);
    Task          Delete(string identifier);
    Task<IEnumerable<string>> List(FileSearchObject? so = null);
    // ...
}

That's the core of Regira.IO.Storage (Apache-2.0). The trick that makes implementations truly interchangeable is that every method speaks in identifiers — paths relative to the service's root, like invoices/2024/inv-001.pdf — never absolute paths or URLs. Application code stores and passes identifiers; only the implementation knows what they resolve to.

Development registers the local disk:

services.AddSingleton<IFileService>(_ =>
    new BinaryFileService(new FileSystemOptions { RootFolder = "/var/app/uploads" }));

Production swaps the registration — and nothing else (AzureCommunicator/BinaryBlobService live in the companion package Regira.IO.Storage.Azure):

var communicator = new AzureCommunicator(new AzureOptions
{
    ConnectionString = configuration["Storage:ConnectionString"],
    ContainerName    = "uploads"
});
services.AddSingleton<IFileService>(_ => new BinaryBlobService(communicator));

Every consumer — upload endpoints, PDF generators, the attachment feature of your entity framework — keeps calling Save("invoices/2024/inv-001.pdf", stream) and never learns the difference. The identifiers even keep your "folder" structure intact across backends, since blob names encode it just fine.

The same package family covers the less common seats too: NetworkFileService for credentialed UNC shares, SftpService (SSH.NET) for the partner who insists on SFTP, GitHubService for versioned config-file storage, and ZipFileService — which is sneakily useful in tests: point the code at an in-memory ZIP archive and assert on its entries, no disk I/O at all.

Two habits to go with the seam:

  • Bind options from configuration, so the backend really is an environment decision (FileSystemOptions locally, AzureOptions in the cloud — both are just config sections).
  • Store identifiers in your database, not absolute URIs. GetAbsoluteUri(identifier) exists for the moment you need a real address; the stored key stays portable, so migrating your file store later is a copy job, not a data migration. And when that copy job comes, it's the same interface on both ends — we built a small config-driven sync tool that way.

Docs: regira.github.io/Regira-Packages — IO.Storage