Skip to content
Slicekit

Backend guides

File storage

Upload, store and serve files through the S3-compatible storage abstraction (MinIO locally).

View .md
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;
MethodReturnsNotes
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)ResultIdempotent 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).

SettingEnv varDefaultNotes
Storage:BucketStorage__BucketrequiredBucket the SDK targets.
Storage:RegionStorage__Regionus-east-1Real AWS S3 region. MinIO ignores it, but a value is needed.
Storage:ServiceUrlStorage__ServiceUrlemptyEmpty means real AWS S3. Set it for MinIO or another S3-compatible host.
Storage:AccessKeyStorage__AccessKeyemptyEmpty plus real AWS uses the default credential chain.
Storage:SecretKeyStorage__SecretKeyempty
Storage:MaxFileSizeBytesStorage__MaxFileSizeBytes26214400 (25 MB)Per-file cap. Oversize files come back as ValidationError.
Storage:MaxFilesPerRequestStorage__MaxFilesPerRequest20Soft cap surfaced to callers. Enforce it at your endpoint.
Storage:AllowedContentTypesStorage__AllowedContentTypes__0, etc.[]Empty allows any content type. Otherwise an allow-list.
Storage:PresignedUrlDefaultMinutesStorage__PresignedUrlDefaultMinutes15Default expiry when GetPresignedUrlAsync is called without an explicit window.

A few rules the DI layer enforces so operators only ever set the URL:

  • ServiceUrl and ForcePathStyle are coupled. When a ServiceUrl is configured the client flips to path-style addressing automatically, and switches to http:// when the URL is plaintext.
  • Cross-field validation (StorageSettings.Validate): when ServiceUrl is set, both AccessKey and SecretKey are required. The default AWS credential chain is only reachable when ServiceUrl is 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_KEY and STORAGE_SECRET_KEY blank. The SDK uses the default credential chain (IAM role, environment, ~/.aws).
  • Real AWS S3 with static keys: leave STORAGE_SERVICE_URL blank, set STORAGE_ACCESS_KEY and STORAGE_SECRET_KEY.
  • Self-hosted MinIO or another S3-compatible host: set all three.

Checklist

  • docker compose up -d minio minio-bootstrap brings MinIO up and creates the slicekit-dev bucket.
  • Inject IFileStorage into your handler and call PutAsync / PutManyAsync.
  • Persist the returned StoredFile.Key against your domain row.
  • Serve bytes with GetAsync (gated) or hand out a GetPresignedUrlAsync URL (direct).
  • Map result.Error.ToProblem() for the ValidationError and NotFoundError cases.
  • Confirm the object under slicekit-dev/ in the MinIO console at http://localhost:9001.