Storage & Mail

Storage

Object storage through the storage.Store interface, with an S3-compatible implementation, without importing the AWS SDK.

In the shop sample app, the admin surface uploads product images to object storage. Business code sees exactly one thing for this: the storage.Store interface. Whether AWS S3, MinIO, or RustFS sits behind it, and how the AWS SDK gets configured, is the concern of the wiring in main.go.

The one deliberate abstraction

The kit's design principle is "centralize, don't abstract": fasthttp/chi, pgx, and asynq don't hide behind wrappers of their own. Storage is one of the exceptions where the interface is deliberate:

  • The AWS SDK is a large dependency with a configuration world of its own. storage.Store lives in the kit's storage package and imports nothing from the AWS SDK, so your service layer doesn't have to either. The SDK is isolated in the storage/s3 subpackage, imported only by main.go.
  • In tests the interface swaps trivially for an in-memory fake: no MinIO container needed for a service unit test.
  • The interface makes the driver replaceable: if you ever need to switch storage backends (a different cloud, a self-hosted solution, a CDN-based storage), all it takes is a new driver satisfying storage.Store: the service layer stays untouched. Implementing a new driver is deliberately cheap: the interface is only 6 methods, and storage/s3 is the pattern to follow (with a compile-time interface assertion: var _ storage.Store = (*Client)(nil)).

This package is entirely opt-in: the kit doesn't force its use. If you don't want storage.Store, you simply wire your own file-handling solution into your service layer. The rest of the kit works independently of that choice. A project generated with new project already ships a rustfs service in compose.yml and matching S3_* lines in .env.example, but actually wiring up s3.Config (adding the field, calling s3.New) always stays a decision the developer makes.

storage.Store provides a single, uniform interface for object storage, and the S3_* env vars configure the connection details for the driver behind it.

The Store interface

import "github.com/gp-system/gpsystem/storage"

// Store is a flat key/value object store.
type Store interface {
    // Upload streams an object to the store under key.
    Upload(ctx context.Context, key string, r io.Reader, contentType string, size int64) error
    // NewReader opens the object for streaming reads. The caller must Close it.
    NewReader(ctx context.Context, key string) (io.ReadCloser, error)
    // Delete removes the object; deleting a missing key is not an error.
    Delete(ctx context.Context, key string) error
    // URL returns the public URL for the key (PublicBaseURL + key).
    URL(ctx context.Context, key string) (string, error)
    // SignedURL returns a pre-signed GET URL valid for ttl.
    SignedURL(ctx context.Context, key string, ttl time.Duration) (string, error)
    // PublicBaseURL returns the base under which objects are publicly served.
    PublicBaseURL() string
}

A few things about the contract:

  • Upload streams: it takes an io.Reader, not a []byte, so uploading a large file doesn't fill memory. Passing size (when known) helps the client; with 0 or negative it omits the Content-Length.
  • NewReader streams as well: the returned io.ReadCloser must be closed by the caller.
  • Delete is idempotent: deleting a missing key is not an error.
  • URL is for public buckets/CDNs: the configured PublicBaseURL + key. It errors when no PublicBaseURL is configured.
  • SignedURL gives a time-limited, signed GET URL for private objects: the client downloads straight from storage, bypassing the backend.

storage/s3: the built-in implementation

import "github.com/gp-system/gpsystem/storage/s3"

store, err := s3.New(ctx, cfg.Storage) // *s3.Client implements storage.Store

s3.New(ctx, cfg) works with any S3-compatible service:

  • AWS S3: leave Endpoint empty; with empty AccessKey/SecretKey the default AWS credential chain applies (IAM role, env, ~/.aws).
  • MinIO, RustFS, and friends: set Endpoint and UsePathStyle: true (most self-hosted S3 expects /bucket/key addressing instead of virtual hosts), plus static keys.

The client emits an OTel span per S3 API call (otelaws instrumentation); this is free with a no-op tracer provider. With telemetry enabled, though, the upload sits right there in the trace waterfall next to the DB queries.

Configuration

Compose s3.Config into your project config under an S3_ prefix:

type Config struct {
    // ...
    Storage s3.Config `envPrefix:"S3_"`
}
VariableDefaultMeaning
S3_ENDPOINTemptyempty → AWS; for MinIO/RustFS the service address
S3_REGIONus-east-1
S3_BUCKETrequired
S3_ACCESS_KEY / S3_SECRET_KEYemptyempty → default AWS credential chain
S3_USE_PATH_STYLEfalsetrue for most self-hosted S3
S3_PUBLIC_BASE_URLemptybase for URL() (CDN or public bucket endpoint)

The full list lives in the configuration reference.

A project generated with new project already runs a rustfs service (self-hosted, S3-compatible) in compose.yml, and .env.example ships the matching S3_* lines, but wiring s3.Config (adding the field to config.go, calling s3.New in main.go) stays opt-in, per the pattern above.

In the shop: product image upload

The admin surface's image upload follows the usual layering: the handler only extracts the multipart file, and the business logic (key building, upload, URL persistence) lives in the service, which depends on storage.Store:

internal/modules/shop/surfaces/admin/service/product_image.go
type ProductImageService struct {
    store storage.Store
    repo  ProductRepository
}

func (s *ProductImageService) Upload(
    ctx context.Context, productID string,
    r io.Reader, contentType string, size int64,
) (string, error) {
    key := "products/" + productID + "/image"
    if err := s.store.Upload(ctx, key, r, contentType, size); err != nil {
        return "", err
    }
    url, err := s.store.URL(ctx, key)
    if err != nil {
        return "", err
    }
    return url, s.repo.SetImageURL(ctx, productID, url)
}

Extracting the multipart file is the single engine-specific point:

func (h *Handler) UploadProductImage(c fiber.Ctx, productId string) error {
    fh, err := c.FormFile("image")
    if err != nil {
        return err
    }
    f, err := fh.Open()
    if err != nil {
        return err
    }
    defer f.Close()

    url, err := h.images.Upload(c.Context(), productId,
        f, fh.Header.Get("Content-Type"), fh.Size)
    if err != nil {
        return err
    }
    return c.JSON(fiber.Map{"url": url})
}

For private content (say, an order export), hand out a signed, expiring link instead of a public URL:

url, err := s.store.SignedURL(ctx, "exports/orders-2026-07.csv", 15*time.Minute)

The wiring happens in main.go: the client is constructed there and enters Dependencies as a storage.Store:

store := must(s3.New(ctx, cfg.Storage))
deps := shop.Dependencies{ /* ..., */ Storage: store }

Testing: an in-memory fake

Because the service depends only on the interface, the test gets a few-line fake: no AWS SDK, no container:

type fakeStore struct{ objects map[string][]byte }

func (f *fakeStore) Upload(_ context.Context, key string, r io.Reader, _ string, _ int64) error {
    b, err := io.ReadAll(r)
    if err != nil {
        return err
    }
    f.objects[key] = b
    return nil
}

func (f *fakeStore) URL(_ context.Context, key string) (string, error) {
    return "https://cdn.test/" + key, nil
}

// NewReader, Delete, SignedURL, PublicBaseURL: analogous...
func TestUploadStoresImageAndURL(t *testing.T) {
    store := &fakeStore{objects: map[string][]byte{}}
    svc := service.NewProductImageService(store, repo)

    url, err := svc.Upload(ctx, "p42", strings.NewReader("png-bytes"), "image/png", 9)

    require.NoError(t, err)
    require.Equal(t, "https://cdn.test/products/p42/image", url)
    require.Contains(t, store.objects, "products/p42/image")
}

At the integration level, the kit's own tests spin up MinIO from Docker. You can do the same in your project when you want the full S3 path covered.

Media processing (image conversion, thumbnail generation, validation pipelines) is deliberately not part of the kit. It tends to be project-specific. Build it on top of storage.Store.

Patterns used

Interface segregation / dependency inversion (the split between the driver-free storage.Store and the AWS-SDK-wrapping storage/s3.Client, with a compile-time interface assertion). This is one of the kit's three stated exceptions; see Design patterns: Architectural principles.

Copyright © 2026