The gdenticon Refactoring Log — Part 2

The part that actually needed refactoring

gdenticon v1 could already generate avatars as a Go package. It had mirrored grids, pattern filtering, and deterministic colour selection. Judged only by the result, it was not an unusable release.

Its use was still centred on Go code and local PNG files. Callers assembled the settings themselves and passed them to Save for disk output. Later, I wanted gdenticon to work as a standalone tool, produce several image formats, and send the same avatar to memory, an HTTP response, or another destination. The original API was no longer a comfortable fit for those uses.

I could have kept extending the old structure, but each change spread beyond one layer. WritePNG did more than encode PNG: it accepted the original identifier and called Generate internally. The outer Save function then handled content hashing, filename selection, and persistence. Adding SVG or JPEG would either duplicate a similar entry point or make Save responsible for a growing set of formats and output rules.

That became the reason to start v2. Before adding more features, I needed to split the fixed path into three steps: produce a reusable avatar, choose an encoding, and let the caller choose the destination.

v1: a Go package that eventually reached Save

v1 had no CLI. Callers first assembled a model.Image in Go:

img := model.Image{ GridSize: 5, ImageSize: 256, Mirror: true, Hash: gdenticon.SHA256, } 

That value mixed two kinds of settings. Grid, mirroring, digest, and colours described how to generate the avatar. ImageSize and Format described how to encode it.

They then called:

path, err := gdenticon.Save(id, img, "") 

Save called WritePNG, and WritePNG called Generate. The complete path looked like this:

Go caller → model.Image → Save → WritePNG → Generate → encode PNG into bytes.Buffer → derive a filename from the bytes → write the file 

The path worked, but it left several problems for the next round of changes.

The problem was not PNG; it was the cost of adding another encoder

The v1 model.Image reserved names for SVG, PNG, JPEG, GIF, BMP, TIFF, and WEBP. Those constants were placeholders for future output work. They did not mean every format was implemented, and their presence was not a refactoring argument.

The architectural problem was the call direction. Save called WritePNG, and WritePNG triggered Generate. Continuing with the same design would either create parallel entry points such as WriteJPEG(id, img) and WriteSVG(id, img), or turn Save into a dispatcher that still owned generation and persistence.

Both approaches could work. Neither made an encoder independently replaceable. Every new format would still touch the original input, generation settings, and file policy. That was the signal to change the boundary.

WritePNG knew about input, generation, and encoding

The v1 WritePNG accepted the original identifier and the complete model.Image, not an avatar that had already been generated:

func WritePNG(w io.Writer, id string, img model.Image) error { icon, err := Generate(id, img) if err != nil { return err } return png.Encode(w, raster(icon, img.ImageSize)) } 

PNG output became another entry into the generation pipeline. A caller that already had an Icon had no common Write function to continue with. Every additional encoder would also need a decision about generation and size validation.

The core package also owned the filesystem

Save selected the temporary directory, created the gdenticon folder, derived a content-based filename, and wrote the file. That was convenient for callers that wanted a local file, but it was not a natural policy for every use case.

An HTTP response, an object-storage upload, or an in-memory result does not need a directory or filename. Once local persistence became the default endpoint, other callers had to bypass Save and reconstruct the path themselves.

The full encoded image was buffered first

To derive a filename from the output, v1 encoded the complete PNG into a bytes.Buffer:

var buf bytes.Buffer if err := WritePNG(&buf, id, img); err != nil { return "", err } path := filepath.Join(dir, fileName(buf.Bytes())) if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { return "", err } 

A default-sized avatar is small, so this was not an immediate failure. It did mean that saving waited for the full encoding, then read the same bytes for hashing and disk output. That path was worth removing before considering larger batches.

I am not attaching a performance number to this structural change. Without a benchmark, less buffering is not evidence of a measured speedup. What can be stated is that the new save path no longer requires the core package to retain the complete encoded image.

The v2 goal: separate three different jobs

The issue was not the number of files or how the folders looked. Three different jobs had become one fixed route:

  1. derive an avatar from the input;
  2. encode that avatar as an image format;
  3. decide where the encoded image belongs.

v2 gives each job a boundary:

GenerateOptions → Generate → Icon → Write(io.Writer) → file, memory, HTTP response, or object storage 

The pattern algorithm remains inside Generate. PNG, SVG, and the other encoders belong to Write. Directories, cache entries, and replacement policy move to the filesystem package. The CLI composes those parts without moving its persistence decisions into the core package.

Split generation settings from encoding settings

v2 removed the separate model package and moved its public types into gdenticon. More importantly, the old model.Image became two option types.

GenerateOptions contains the values needed to derive a logical avatar:

type GenerateOptions struct { GridSize int Mirror bool Style PatternStyle Digest DigestFunc Foreground ColorFunc Background color.Color Format Format } 

WriteOptions contains values used during encoding:

type WriteOptions struct { Size int JPEGQuality int } 

Format remains on GenerateOptions and Icon so the generated value carries the encoder selected for the next stage. Pixel-size validation is delayed until Write. SVG does not need to satisfy raster-size constraints, while PNG and JPEG do.

The point is not the extra struct. Generation no longer has to carry every rule imposed by an encoder.

Make Icon the real intermediate value

v2 Generate produces an Icon and stops:

icon, err := gdenticon.Generate(id, opts) 

Icon stores the grid, filled cells, colours, and target format. It describes the avatar without being an image file:

type Icon struct { GridSize int Filled []bool Background color.Color Foreground color.Color Format Format } 

v1 already had an Icon; the concept itself is not new. The change is that encoders now consume this value directly instead of accepting an identifier and regenerating it.

This also marks the limit of the architectural refactoring. Hash-derived entropy, mirrored grids, density limits, four-connected-component filtering, and colour selection already existed in v1. v2 largely kept that core while changing how the surrounding code reaches and leaves it.

There is still room to optimise the algorithm, but changing it at the same time as the output pipeline would make the source of any improvement harder to measure.

Use one Write entry for every format

The encoding API is now:

func Write(w io.Writer, icon Icon, opts WriteOptions) error 

Write selects PNG, SVG, JPEG, GIF, BMP, or TIFF from icon.Format. Callers no longer need one public function per encoder or the original identifier.

The io.Writer boundary is the more important part. The core package writes encoded bytes without creating or closing a file. The destination can be:

  • a bytes.Buffer in memory;
  • an http.ResponseWriter;
  • an os.File;
  • an object-storage upload stream;
  • an io.MultiWriter with several destinations.

Multi-format support is therefore part of the real output path rather than a list of constants. The CLI now exposes --format, and that choice reaches the shared Write function.

Move persistence out of the core package

v2 removed Save from the core package and introduced pkg/gdenticon/file for directories, temporary files, replacement, and content-addressed caching.

The CLI composes encoding and caching like this:

entry, err := iconfile.PutCacheEntry( dir, icon.Format.Extension(), 0o644, func(w io.Writer) error { return gdenticon.Write(w, icon, gdenticon.WriteOptions{Size: f.size}) }, ) 

The filesystem layer supplies a writer. PutCacheEntry uses io.MultiWriter to stream the encoding into a temporary file and SHA-256 at the same time, without first retaining the complete image in memory.

Cache hits, concurrent creation, and Windows replacement behaviour belong here. They matter to the CLI but are not identicon-generation rules. A caller that only needs bytes in memory can ignore this package completely.

Introduce the CLI in v2.0.0

v1 was primarily a Go package. v2.0.0 introduced the CLI, which had to translate command-line strings into GenerateOptions and WriteOptions before joining the new generation, encoding, and filesystem pipeline.

v2.0.0 supports both direct generation and an interactive mode, so main chooses how input is collected:

func runProgram(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if interactiveRequested(args) { return runInteractive(stdin, stdout, stderr) } return run(args, stdout, stderr) } 

Interactive mode does not duplicate option parsing or generation. It passes the arguments following generate through the same Cobra command tree and records the result through a callback. Direct and interactive requests share the same path after input collection.

Usage errors still return 2 with command help, while runtime failures return 1 with their cause. Interactive mode keeps the session alive and changes only how the same errors are presented.

This work is far from the avatar algorithm, but it keeps the newly added terminal state and session management out of the package that generates images.

The current v2 request path

Running:

gdenticon alice@example.com --grid 7 --format svg 

now follows this route:

main → choose direct or interactive input → validate and translate CLI options → Generate an Icon → Write the selected format to an io.Writer → store or reuse a content-addressed file → print the final path 

Compared with v1, the pattern algorithm has not been wrapped in additional layers. The responsibilities around it are simply clearer:

  • Generate does not own image size or a file path;
  • Write does not regenerate the avatar or select a destination;
  • the filesystem package does not know about digests or grids;
  • the CLI only connects user input to those capabilities.

That is the main v1-to-v2 refactoring. It does not prove a speedup by itself, but it creates places where later changes can be measured and replaced independently.

What remains open

Separating generation, encoding, and persistence leaves plenty of work inside avatar generation itself.

The current algorithm turns a digest into a renewable bitstream, generates a mirrored grid, and rejects candidates that are too empty, too full, or too fragmented using density and four-connected components. That core idea already existed in v1; v2 did not replace it wholesale.

The next questions are more specific: which calculations can be removed, whether candidate scoring should change, where the grid and raster paths allocate unnecessarily, and what each change does in a benchmark. Until those measurements exist, I will not present an architectural cleanup as a performance result.

Next

The next part will move inside Generate, starting with how digest bits reach the mirrored grid and how balanced filters candidates, then identifying the parts that still have room for optimisation.

GitHub Repository: https://github.com/hourinX/gdenticon