FileStore started with a problem in a client's project: services generated files, but those files ended up in different places.
Finding a file later was harder than it should have been. The database did not have a clear mapping between the application's records and the files on disk. Planning a move to another server made that problem especially visible.
I wanted one place to ask: which file is this, which project owns it, and where is it stored?
That led to a dedicated storage API. The client had several projects, so I added ApiClientId to let those projects use the same service while keeping their file records associated with the right client.
This is the first article in a FileStore series. Here I will walk through the structure and file mapping. The next articles focus on choosing storage providers and managing the database with FluentMigrator.
Start with a reference, rather than a physical path
A physical path describes where a file lives today. It is not a great identity for the application to build around, especially when a server move is already on the horizon.
FileStore gives each file a Reference. The service keeps the physical location in its metadata record. The calling project can keep the reference instead of knowing how storage is arranged.
Here is the file entity from the repository, with unused imports omitted:
public class File : EntityBase
{
public Guid Reference { get; set; }
public Guid FileTypeId { get; set; }
public Guid ApiClientId { get; set; }
public string FileName { get; set; }
public string FullPath { get; set; }
public long Size { get; set; }
public DateTime DateCreated { get; set; }
public bool MarkedForDeletion { get; set; }
}
Reference identifies the stored file. FullPath records its storage location, and FileName keeps the name used when returning it to the caller. ApiClientId associates the record with a registered client.
That mapping does not move files automatically during a server migration. It gives me an inventory and a place to manage the mapping. Moving the bytes, checking them, and updating locations still need a deliberate process.
One API used by several projects
The API client contains the configuration needed to choose storage:
public class ApiClient : EntityBase
{
public string Name { get; set; }
public string ApiKey { get; set; }
public string Secret { get; set; }
public string BasePath { get; set; }
public ClientStorageType StorageType { get; set; }
public string StorageSettings { get; set; }
}
The credentials in this model identify the calling application. They are not values to put in public JavaScript or in a download link. This historical implementation stores credential values on the entity; I would revisit credential storage and logging before reusing it today.
The repository targets .NET Core 3.1. These articles explain that implementation, rather than presenting it as a current starter template. The code samples are reading extracts, not a complete service to deploy unchanged.
The upload handler resolves the client
The controller accepts a multipart file and sends an UploadFileCommand. The handler resolves the current client, loads its configuration and asks the storage factory for the right service:
public async Task<FileResponse> Handle(
UploadFileCommand request,
CancellationToken cancellationToken)
{
var apiClientId = Guid.Parse(currentUserService.ApiClientId);
var apiClient = await apiClientRepository.GetByIdAsync(apiClientId);
return await storageFactory
.GetStorageService(apiClient)
.StoreFileAsync(apiClient, request.File);
}
The client ID comes from the current-user service, not from an arbitrary upload form field. The project supports a JWT path and a legacy header-based credential path. Having the client ID in a record is only one part of access control; authenticating the request and checking ownership on every operation are still essential.
Validate, write the bytes, then save metadata
The storage implementations check the client's allowed content types and reject empty files. They then build a metadata record:
var fileEntity = new Domain.Entities.File
{
Id = Guid.NewGuid(),
FileName = file.FileName,
Reference = Guid.NewGuid(),
ApiClientId = apiClient.Id,
Size = file.FileBytes.Length,
DateCreated = DateTime.UtcNow,
FileTypeId = fileType.Id
};
This extract is from the local provider's FileModel overload. The IFormFile upload path first constructs that model. The Azure provider also accepts both forms.
The local provider writes the content to disk, records the path, and adds the database row:
fileEntity = SaveFileToDisk(file.FileBytes, fileEntity, apiClient);
await fileRepository.AddAsync(fileEntity);
return new FileResponse
{
Reference = fileEntity.Reference,
FileName = fileEntity.FileName,
FileUrl = GetFilePublicUrl(httpContextAccessor, fileEntity.Reference)
};
There are two separate writes here: storage and SQL Server. They are not one atomic transaction. If the file write succeeds and the database insert fails, a file can be left without its metadata row. Cleanup or reconciliation is something I would add around that boundary.
The helper's name GetFilePublicUrl also needs context: returning an API URL does not prove that the file is anonymously available, or that a caller has permission to read it. Authentication and ownership checks remain separate concerns.
Look up the file for the current client
The Dapper repository uses both the file reference and the client ID:
return await cn.QueryFirstAsync<File>(
@"SELECT * FROM [File]
WHERE [Reference] = @Reference
AND [MarkedForDeletion] = 0
AND [ApiClientId] = @ApiClientId",
new
{
ApiClientId = apiClientId,
Reference = reference
});
The parameters are supplied separately from the SQL text. Including ApiClientId means that knowing another client's reference is not enough for this lookup to return its row.
One detail I would change is the missing-row behavior: QueryFirstAsync throws when there is no match. Some callers then check for null, but that check will not handle this exception. I would align the repository contract and the API's not-found response.
Return a download without exposing the storage layout
The download handler resolves the client's storage service and returns a FileModel. The controller turns it into a file response:
var result = await Mediator.Send(
new GetFileQuery { Reference = reference })
.ConfigureAwait(false);
return File(result.FileBytes, result.ContentType, result.FileName);
The calling project uses the reference; it does not need to read a disk path or contact Azure directly. In the current implementation, the download is loaded into memory. For larger files, I would look at streaming and cancellation instead of returning a full byte array.
What this solved, and what it did not
The useful change was having a dedicated place for file metadata and storage operations. Several projects could use the same API, and each file record had an explicit client association and storage location.
That made the file mapping easier to reason about. It did not remove the need for backups, migration checks, file validation or careful access control.
If I were revisiting it today, I would start with:
- Consistent authentication and ownership checks for upload, download and delete.
- File-size limits and content checks beyond the caller-supplied MIME type.
- Private storage for private documents.
- Cleanup when a storage write and database write disagree.
- Correct not-found responses and tests for cross-client access.
- A migration process that checks the stored bytes as well as the database rows.
The idea came from an everyday maintenance problem. The API gave that problem a clear boundary: applications know the reference, while FileStore owns the storage mapping.
Explore the project
The examples follow the file entity, upload handler, and file repository.