Extra guides

Storage, Assets, And Media

Understand GoLazy object storage, asset export, file catalogs, and generated media variants.

By Guillermo Alvarez - Published - Updated

The layers

The media stack is deliberately split:

  • lazystorage stores object bytes by key.
  • lazyassets registers, fingerprints, serves, and exports application assets.
  • lazyfiles gives stored bytes stable file IDs and fallback URLs.
  • lazymedia records generated variants such as thumbnails or previews.

Keep those layers separate in application services. Controllers should call product-level operations such as "store avatar" or "preview document", not assemble storage keys, write repositories, and generate variants inline.

Storage

golazy.dev/lazystorage is the byte layer. Its smallest interface opens an object:

type Storage interface {
    Open(context.Context, string, ...any) (lazystorage.File, []any, error)
}

Optional interfaces add capabilities:

type Writer interface {
    Put(context.Context, string, io.Reader, ...any) (lazystorage.Info, []any, error)
}

type Deleter interface {
    Delete(context.Context, string, ...any) ([]any, error)
}

type Lister interface {
    List(context.Context, string, ...any) (lazystorage.Iterator, []any, error)
}

type URLer interface {
    URL(context.Context, string, ...any) (lazystorage.URL, []any, error)
}

Backends consume the options they understand and return the rest. Object keys use io/fs path rules: relative, clean, no leading slash, and no ...

The local backend is lazystorage.NewFilesystem. The embedded S3-compatible backend is golazy.dev/lazystorage/s3:

storage := s3.New(
    s3.WithEndpoint(os.Getenv("ASSETS_S3_ENDPOINT")),
    s3.WithRegion(os.Getenv("ASSETS_S3_REGION")),
    s3.WithBucket(os.Getenv("ASSETS_S3_BUCKET")),
    s3.WithCredentials(
        os.Getenv("ASSETS_S3_ACCESS_KEY_ID"),
        os.Getenv("ASSETS_S3_SECRET_ACCESS_KEY"),
    ),
    s3.WithPublicBaseURL(os.Getenv("ASSETS_PUBLIC_URL")),
)

PostgreSQL-backed storage lives in golazy.dev/pg/pgstorage; read PostgreSQL Storage.

Assets

golazy.dev/lazyassets owns application assets, not uploaded user files. A Registry maps logical paths such as /styles.css to registered assets and, in production mode, to content-hashed permanent paths with ETags and integrity metadata.

Generated asset packages can implement:

type Source interface {
    Assets(*lazyassets.Registry) error
}

Applications usually let lazyapp register embedded public files and generated asset sources. The registry then serves assets through Handler and installs template helpers:

  • asset_path
  • asset_integrity
  • stylesheet
  • importmap
  • permalink

For deploys, assets can be exported to any lazystorage.Writer:

err := registry.Upload(
    ctx,
    storage,
    lazyassets.WithUploadPrefix("assets"),
)

There is no separate asset repository backend. If the export target should be PostgreSQL, use pgstorage.New(db) as the storage writer; read PostgreSQL Asset Uploads.

File catalog

golazy.dev/lazyfiles assigns stable IDs and metadata to stored bytes. The repository contract stores file records and locations:

type Repository interface {
    Put(context.Context, lazyfiles.File, lazyfiles.Location, ...any) (lazyfiles.File, []any, error)
    Find(context.Context, lazyfiles.Query, ...any) (lazyfiles.File, []lazyfiles.Location, []any, error)
    Delete(context.Context, string, ...any) ([]any, error)
}

type Lister interface {
    List(context.Context, lazyfiles.ListQuery, ...any) ([]lazyfiles.StoredFile, []any, error)
}

Files combines a repository with named lazystorage backends:

files := &lazyfiles.Files{
    Repository: filesjsonlRepo,
    Storages: map[string]lazystorage.Storage{
        "local": lazystorage.NewFilesystem("storage/objects"),
    },
    DefaultStorage: "local",
    RoutePrefix:    "/files",
    SigningKey:     []byte(os.Getenv("FILE_SIGNING_KEY")),
}

The JSONL repository lives in golazy.dev/lazyfiles/jsonl. PostgreSQL-backed catalogs live in golazy.dev/pg/pgfiles; read PostgreSQL File Catalogs.

File URLs and controller fallback

Ask the file service for URLs:

href, _, err := files.URL(
    ctx,
    file.ID,
    lazystorage.ExpiresIn{Duration: 15 * time.Minute},
)

If the active storage implements lazystorage.URLer, Files.URL returns the direct backend URL. Otherwise it returns a signed fallback route under RoutePrefix.

Install files.Handler as middleware when fallback routes should be served by the app:

Middlewares: []lazydispatch.Middleware{
    lazydispatch.MiddlewareFunc(files.Handler),
},

Controllers should still call an app service. The service can store the upload, return a file ID, and decide whether a controller should redirect, render a URL, or enqueue follow-up media work.

Media variants

golazy.dev/lazymedia records generated representations of stored files. It does not own byte storage. It depends on a file service, a variant repository, and a processor:

type FileStore interface {
    Open(context.Context, string, ...any) (io.ReadCloser, lazymedia.File, []any, error)
    Put(context.Context, io.Reader, ...any) (lazymedia.File, []any, error)
    URL(context.Context, string, ...any) (string, []any, error)
}

type Repository interface {
    FindVariant(context.Context, string, string, ...any) (lazymedia.Variant, []any, error)
    SaveVariant(context.Context, lazymedia.Variant, ...any) (lazymedia.Variant, []any, error)
    DeleteVariant(context.Context, string, string, ...any) ([]any, error)
}

type VariantLister interface {
    ListVariants(context.Context, lazymedia.VariantListQuery, ...any) ([]lazymedia.Variant, []any, error)
}

type Processor interface {
    Process(context.Context, lazymedia.Source, lazymedia.Request, ...any) (lazymedia.Result, []any, error)
}

Adapt lazyfiles.Files to FileStore in your app. The adapter is the right place to translate media options into file-catalog options:

type MediaFiles struct {
    Files *lazyfiles.Files
}

func (m MediaFiles) Put(ctx context.Context, body io.Reader, options ...any) (lazymedia.File, []any, error) {
    if name, remaining, ok := lazystorage.Take[lazymedia.OutputFilename](options); ok {
        options = append(remaining, lazyfiles.Filename{Name: name.Name})
    }
    file, remaining, err := m.Files.Put(ctx, body, options...)
    return mediaFile(file), remaining, err
}

The JSONL variant repository lives in golazy.dev/lazymedia/jsonl. PostgreSQL-backed variants live in golazy.dev/pg/pgmedia; read PostgreSQL Media Variants.

Compose the service

media := &lazymedia.Media{
    Files:      MediaFiles{Files: files},
    Repository: mediaRepo,
    Processor:  imageProcessor,
}

url, _, err := media.URL(ctx, lazymedia.Request{
    SourceFileID: file.ID,
    VariantKey:   "preview",
})

The first request generates and records the variant. Later requests reuse the ready output unless lazymedia.Regenerate{} is passed.

Development panel inspection

The lazy development panel has a Media tab when the running app exposes storage, file, or media services through lazyapp.Config:

lazyapp.Config{
    Storages: map[string]lazystorage.Storage{
        "local": lazystorage.NewFilesystem("storage/objects"),
    },
    Files: files,
    Media: media,
}

Files.Storages are also discovered automatically, so apps that already pass a *lazyfiles.Files service usually do not need to duplicate the storage map. In lazydev builds, the Media tab lists configured storages and their objects, cataloged files across storages, and variants for each selected source file. It can upload, download, remove, and show direct or fallback URLs when the underlying storage/file service supports those operations. These control-plane endpoints are not compiled into production builds.

Helpers and controllers

Use template helpers for application assets, Files.URL for stored-file URLs, and app-owned service methods from controllers. Keep controller code focused on HTTP: parse the request, call the service, and render or redirect.

This keeps backend decisions in one place and lets tests swap filesystem, S3, PostgreSQL, JSONL, or in-memory fakes without changing controller actions.