Storage
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.Storelives in the kit'sstoragepackage and imports nothing from the AWS SDK, so your service layer doesn't have to either. The SDK is isolated in thestorage/s3subpackage, imported only bymain.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, andstorage/s3is 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:
Uploadstreams: it takes anio.Reader, not a[]byte, so uploading a large file doesn't fill memory. Passingsize(when known) helps the client; with0or negative it omits the Content-Length.NewReaderstreams as well: the returnedio.ReadClosermust be closed by the caller.Deleteis idempotent: deleting a missing key is not an error.URLis for public buckets/CDNs: the configuredPublicBaseURL+ key. It errors when noPublicBaseURLis configured.SignedURLgives 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
Endpointempty; with emptyAccessKey/SecretKeythe default AWS credential chain applies (IAM role, env,~/.aws). - MinIO, RustFS, and friends: set
EndpointandUsePathStyle: true(most self-hosted S3 expects/bucket/keyaddressing 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_"`
}
| Variable | Default | Meaning |
|---|---|---|
S3_ENDPOINT | empty | empty → AWS; for MinIO/RustFS the service address |
S3_REGION | us-east-1 | |
S3_BUCKET | required | |
S3_ACCESS_KEY / S3_SECRET_KEY | empty | empty → default AWS credential chain |
S3_USE_PATH_STYLE | false | true for most self-hosted S3 |
S3_PUBLIC_BASE_URL | empty | base for URL() (CDN or public bucket endpoint) |
The full list lives in the configuration reference.
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:
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})
}
func (h *Handler) UploadProductImage(w http.ResponseWriter, r *http.Request, productId string) {
f, fh, err := r.FormFile("image")
if err != nil {
h.error(w, r, err)
return
}
defer f.Close()
url, err := h.images.Upload(r.Context(), productId,
f, fh.Header.Get("Content-Type"), fh.Size)
if err != nil {
h.error(w, r, err)
return
}
writeJSON(w, map[string]string{"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.
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.