Backend guides
File storage
Upload, store and serve files through the S3-compatible storage abstraction (MinIO locally).
On this page
One seam, two backends
File storage hides behind a single interface, IFileStorage. The implementation talks to an
S3-compatible bucket: real AWS S3 in production, MinIO on your machine. The same AWSSDK.S3
client backs both, so only configuration changes between environments, never your code.
api/src/Slicekit.Core/
Domain/Services/
IFileStorage.cs // the seam: interface plus the record types
Infrastructure/Storage/
S3FileStorage.cs // S3-backed implementation, internal
StorageSettings.cs // bound config, validated at startup
IFileStorage lives in Slicekit.Core/Domain/Services/IFileStorage.cs. The implementation is
internal, so handlers depend only on the interface. No HTTP endpoint ships: the upload shape is
application-specific, so you wire your own slice (see Adding a vertical slice).
The interface
Every method returns Result<T> (or Result for DeleteAsync), so failures arrive as AppError
rather than exceptions. The shared ToProblem() mapping turns those into ProblemDetails when you
surface them from an endpoint.
namespace Slicekit.Core.Domain.Services;
public interface IFileStorage
{
Task<Result<StoredFile>> PutAsync(FileUpload file, CancellationToken ct = default);
Task<IReadOnlyList<Result<StoredFile>>> PutManyAsync(
IReadOnlyList<FileUpload> files, CancellationToken ct = default);
Task<Result<FileContent>> GetAsync(string key, CancellationToken ct = default);
Task<Result> DeleteAsync(string key, CancellationToken ct = default);
Task<Result<Uri>> GetPresignedUrlAsync(
string key, TimeSpan? expiresIn = null, CancellationToken ct = default);
}
The record types it trades in:
public sealed record FileUpload(string FileName, string ContentType, long Size, Stream Content);
public sealed record StoredFile(string Key, string ContentType, long Size);
public sealed record FileContent(Stream Content, string ContentType, long Size) : IAsyncDisposable;
| Method | Returns | Notes |
|---|---|---|
PutAsync(FileUpload) | Result<StoredFile> | Validates size and content type. Failures: ValidationError, InternalError. |
PutManyAsync(IReadOnlyList<FileUpload>) | IReadOnlyList<Result<StoredFile>> | One result per input, same order. One bad file does not fail the others. |
GetAsync(key) | Result<FileContent> | NotFoundError when the key is missing. FileContent is IAsyncDisposable. |
DeleteAsync(key) | Result | Idempotent at the S3 layer: missing keys still succeed. |
GetPresignedUrlAsync(key, expiresIn?) | Result<Uri> | Time-limited GET URL. Defaults to PresignedUrlDefaultMinutes. |
Uploading from a handler
Inject IFileStorage and call PutAsync. A single upload is one line:
public sealed class UploadAvatarHandler(IFileStorage files)
{
public async Task<Result<StoredFile>> HandleAsync(FileUpload upload, CancellationToken ct) =>
await files.PutAsync(upload, ct);
}
Batch uploads use PutManyAsync. The batch never aborts: each input gets its own Result<StoredFile>
in input order, so one oversize file does not sink the rest.
public sealed class UploadAttachmentsHandler(IFileStorage files)
{
public async Task<IReadOnlyList<Result<StoredFile>>> HandleAsync(
IReadOnlyList<FileUpload> uploads, CancellationToken ct) =>
await files.PutManyAsync(uploads, ct);
}
Failures come back as AppError: ValidationError for an oversize or disallowed content type,
InternalError for a storage exception. Map them with result.Error.ToProblem() in your endpoint
(see Result and error handling).
Keys are generated, never chosen
You do not pass a key into PutAsync. The storage layer generates one server-side as
yyyy/MM/dd/<guidv7>.<ext> and returns it on StoredFile.Key. The original filename is preserved in
S3 metadata under original-filename (URL-encoded). Generating the key avoids reflecting untrusted
input into the object path and gives you prefix-listable date partitions for free. Persist the
returned key against your domain row; that is the handle you store and download by later.
Serving and downloading
There are two ways to get bytes back out, and the choice is about who streams them.
Stream through your API when you want to gate the download behind a permission check. GetAsync
returns a FileContent, which is IAsyncDisposable, so await using it while you stream:
public sealed class ServeAttachmentHandler(IFileStorage files)
{
public async Task<Result<FileContent>> HandleAsync(string key, CancellationToken ct) =>
await files.GetAsync(key, ct); // NotFoundError when the key is missing
}
Hand out a presigned URL when the bytes do not need to pass through your server. The browser then fetches the object straight from S3, with no server round-trip for the payload:
var result = await files.GetPresignedUrlAsync(key); // default expiry
var result = await files.GetPresignedUrlAsync(key, TimeSpan.FromHours(1)); // explicit expiry
Deleting is idempotent: a missing key still succeeds, so you can call it without a prior existence check.
var result = await files.DeleteAsync(key, ct);
Accepting the upload over HTTP
No upload UI ships in the template. When you add an endpoint that builds a FileUpload from an
IFormFile, the matching frontend slice POSTs multipart/form-data through the apiFetch wrapper.
Pass the FormData through unmodified: do not set Content-Type yourself, because the browser fills
in the multipart boundary for you.
Configuration
Settings bind from the Storage: section and validate at startup via ValidateOnStart(). Every key
has a matching env-var override (see Configuration).
| Setting | Env var | Default | Notes |
|---|---|---|---|
Storage:Bucket | Storage__Bucket | required | Bucket the SDK targets. |
Storage:Region | Storage__Region | us-east-1 | Real AWS S3 region. MinIO ignores it, but a value is needed. |
Storage:ServiceUrl | Storage__ServiceUrl | empty | Empty means real AWS S3. Set it for MinIO or another S3-compatible host. |
Storage:AccessKey | Storage__AccessKey | empty | Empty plus real AWS uses the default credential chain. |
Storage:SecretKey | Storage__SecretKey | empty | |
Storage:MaxFileSizeBytes | Storage__MaxFileSizeBytes | 26214400 (25 MB) | Per-file cap. Oversize files come back as ValidationError. |
Storage:MaxFilesPerRequest | Storage__MaxFilesPerRequest | 20 | Soft cap surfaced to callers. Enforce it at your endpoint. |
Storage:AllowedContentTypes | Storage__AllowedContentTypes__0, etc. | [] | Empty allows any content type. Otherwise an allow-list. |
Storage:PresignedUrlDefaultMinutes | Storage__PresignedUrlDefaultMinutes | 15 | Default expiry when GetPresignedUrlAsync is called without an explicit window. |
A few rules the DI layer enforces so operators only ever set the URL:
ServiceUrlandForcePathStyleare coupled. When aServiceUrlis configured the client flips to path-style addressing automatically, and switches tohttp://when the URL is plaintext.- Cross-field validation (
StorageSettings.Validate): whenServiceUrlis set, bothAccessKeyandSecretKeyare required. The default AWS credential chain is only reachable whenServiceUrlis empty.
Local development with MinIO
docker compose up -d brings up MinIO (port 9000 for the S3 API, 9001 for the console) plus a
one-shot minio-bootstrap container that creates the slicekit-dev bucket. appsettings.json already
points at MinIO with minioadmin/minioadmin, so a fresh checkout works with no env vars. See
Getting started for the full first-run flow.
docker compose up -d minio minio-bootstrap
Open the console at http://localhost:9001 and sign in with minioadmin / minioadmin to watch
objects land under slicekit-dev/yyyy/MM/dd/<guid>.<ext>.
Production
Fill the STORAGE_* block in .env.prod. Three common shapes:
- Real AWS S3 with an IAM role (recommended): leave
STORAGE_SERVICE_URL,STORAGE_ACCESS_KEYandSTORAGE_SECRET_KEYblank. The SDK uses the default credential chain (IAM role, environment,~/.aws). - Real AWS S3 with static keys: leave
STORAGE_SERVICE_URLblank, setSTORAGE_ACCESS_KEYandSTORAGE_SECRET_KEY. - Self-hosted MinIO or another S3-compatible host: set all three.
Checklist
-
docker compose up -d minio minio-bootstrapbrings MinIO up and creates theslicekit-devbucket. - Inject
IFileStorageinto your handler and callPutAsync/PutManyAsync. - Persist the returned
StoredFile.Keyagainst your domain row. - Serve bytes with
GetAsync(gated) or hand out aGetPresignedUrlAsyncURL (direct). - Map
result.Error.ToProblem()for theValidationErrorandNotFoundErrorcases. - Confirm the object under
slicekit-dev/in the MinIO console at http://localhost:9001.