JavaRush /Courses /Go SELF /JSON format versioning — {version, data}

JSON format versioning — {version, data}

Go SELF
Level 46, Lesson 3
Available

1. Why version a JSON format

When you’re just starting to write a program, the data format feels like something eternal: what could possibly change in tasks? And then you suddenly decide that title should be renamed to text, or that the field done should become status: "done"|"todo", or that identifiers must now be strings. And then it turns out that yesterday’s data has suddenly become “unreadable archaeology”.

Versioning is a way to tell yourself (and the program): “Yes, the format changes, and that’s normal.” We’re not trying to predict the future. We just add an explicit switch that the code uses to choose how to read a document. This greatly reduces the chance that your users will send you files with “nothing works”, while you open them in a hex editor and cry.

Imagine our training app — a simple task manager. Let the domain model (how we think about a task inside the program) look like this:

package main

type Task struct {
	ID    int
	Title string
	Done  bool
}

So far so good. But the storage format is already an external contract. And external contracts are best designed to survive your project’s “creative phase” of development.

2. The envelope { "version": N, "data": ... }

To make the format changeable, we don’t try to store “every field in the world” at once. We build an envelope with a version number and a payload. The minimal and readable pattern looks like this:

{
  "version": 2,
  "data": { ... }
}

The trick is that we read the version first, and only then decide what to decode data into. And this is where json.RawMessage becomes the perfect tool: it keeps a chunk of JSON “as is”, without forcing us to choose a struct too early.

In Go, such an envelope can be described like this:

package main

import "encoding/json"

type Envelope struct {
	Version int             `json:"version"`
	Data    json.RawMessage `json:"data"`
}

json.RawMessage is basically []byte, but with “official meaning”: “this is a valid JSON fragment; I’ll parse it later.”

3. DTO vs domain: migration as a separate function

It’s very easy to do it “dirty”: read JSON of an old version and immediately start writing if version == 1 { ... } else { ... }, smearing conversion rules throughout the code. That’s how “version soup” is born — something you’re afraid to touch later.

A better way is to keep a simple idea in mind: the domain model (Task) is what the program works with. And the structs for JSON are DTOs (data transfer objects), i.e., data shapes “for transfer/storage”.

So we’ll create two DTO versions and be able to convert any version into the domain model.

Let version 1 have the field named text, and in version 2 we decide to rename it to title.

package main

type TaskV1 struct {
	ID   int    `json:"id"`
	Text string `json:"text"`
	Done bool   `json:"done"`
}

type TaskV2 struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

Then the migration becomes as boring as possible (and boring migrations are a compliment):

package main

func (t TaskV1) ToDomain() Task {
	return Task{
		ID:    t.ID,
		Title: t.Text,
		Done:  t.Done,
	}
}

And a separate function “from the new version” can exist too, although it’s usually trivial:

package main

func (t TaskV2) ToDomain() Task {
	return Task{ID: t.ID, Title: t.Title, Done: t.Done}
}

4. Compatibility: read all versions, write only the current one

There’s one very practical principle that makes life easier: the program should be able to read old data (otherwise you break users), but it should write only in one current format, otherwise you’ll start breeding a zoo.

Let’s agree that the current version is 2:

package main

const CurrentVersion = 2

This is important not only for writing, but also for making the code “self-documenting”: when you see CurrentVersion, you understand that this is the center of your format’s universe.

5. Two-step read: Envelope → data by version

Below is the core of the whole lecture: how to read {version, data}.

First we decode the envelope, get env.Version and env.Data. Then we do a switch on the version and decode env.Data into the struct of the specific version.

package main

import (
	"encoding/json"
	"fmt"
)

func decodeTasksFromData(version int, data []byte) ([]Task, error) {
	switch version {
	case 1:
		var v1 []TaskV1
		if err := json.Unmarshal(data, &v1); err != nil {
			return nil, err
		}

		out := make([]Task, 0, len(v1))
		for _, t := range v1 {
			out = append(out, t.ToDomain())
		}
		return out, nil

	case 2:
		var v2 []TaskV2
		if err := json.Unmarshal(data, &v2); err != nil {
			return nil, err
		}

		out := make([]Task, 0, len(v2))
		for _, t := range v2 {
			out = append(out, t.ToDomain())
		}
		return out, nil

	default:
		return nil, fmt.Errorf("unsupported version %d", version)
	}
}

Note: migration is not “JSON magic”. It’s ordinary Go code: loops, structs, assignments. And that’s the power: you fully control what counts as a correct transfer of meaning.

6. Strict envelope decoding: unknown fields and trailing data

Now for an important point that people usually understand only after a couple of unpleasant bugs. If your format is a contract, then the envelope must be strict. Otherwise someone will send:

{"version":2,"data":[...],"verison":999}

and you’ll be wondering why nothing works (spoiler: a typo in version).

We can use json.Decoder + DisallowUnknownFields() for the envelope and additionally verify that the input contains exactly one JSON document.

Let’s make a small helper. Yes, it’s a bit “boilerplate”, but it’s a good template.

package main

import (
	"encoding/json"
	"fmt"
	"io"
)

func decodeOneStrict(r io.Reader, v any) error {
	dec := json.NewDecoder(r)
	dec.DisallowUnknownFields()

	if err := dec.Decode(v); err != nil {
		return err
	}

	if err := dec.Decode(&struct{}{}); err != io.EOF {
		if err == nil {
			return fmt.Errorf("unexpected extra JSON value")
		}
		return fmt.Errorf("trailing data: %w", err)
	}
	return nil
}

Here it’s useful to remember Go’s philosophy: errors are values, and we check them explicitly; this is a normal part of controlling program flow. This style of error handling and “adding context” via fmt.Errorf(..., %w) is one of the basic Go patterns.

7. Reading tasks: ReadTasks(r io.Reader)

Now let’s put everything into one function that reads tasks from an io.Reader. This is convenient: you can apply it to a file, to the network, to stdin, to strings.NewReader, to bytes.Buffer — to anything.

package main

import (
	"fmt"
	"io"
)

func ReadTasks(r io.Reader) ([]Task, error) {
	var env Envelope
	if err := decodeOneStrict(r, &env); err != nil {
		return nil, fmt.Errorf("decode envelope: %w", err)
	}

	tasks, err := decodeTasksFromData(env.Version, env.Data)
	if err != nil {
		return nil, fmt.Errorf("decode tasks v%d: %w", env.Version, err)
	}
	return tasks, nil
}

Notice the error text: we added context decode envelope and decode tasks vN. This helps when an error “bubbles up” and you want to understand at which step things broke. Go generally encourages exactly this approach to errors: not only “what broke”, but also “during which action”.

8. Writing tasks: WriteTasks(w io.Writer, tasks []Task)

Reading already supports multiple versions. For writing, we output a single current version so we don’t multiply formats.

We need to: convert domain Task values into TaskV2, wrap them in an envelope, and write it via json.Encoder. For aesthetics, you can also call SetIndent, but that’s optional.

package main

func toV2(tasks []Task) []TaskV2 {
	out := make([]TaskV2, 0, len(tasks))
	for _, t := range tasks {
		out = append(out, TaskV2{ID: t.ID, Title: t.Title, Done: t.Done})
	}
	return out
}

And now writing:

package main

import (
	"encoding/json"
	"io"
)

func WriteTasks(w io.Writer, tasks []Task) error {
	payload := toV2(tasks)

	env := Envelope{
		Version: CurrentVersion,
		Data:    mustJSON(payload),
	}

	enc := json.NewEncoder(w)
	enc.SetIndent("", "  ")
	return enc.Encode(env)
}

There’s one “trick” here: data is a json.RawMessage, while payload is a struct. So we need to convert payload into JSON bytes once. Let’s make a small helper, but carefully: in real code it’s better to return an error instead of panicking. For the lecture, we’ll show a version without panics.

package main

import "encoding/json"

func mustJSON(v any) json.RawMessage {
	b, _ := json.Marshal(v)
	return b
}

If you thought “you can’t do that, the error is ignored” — you’re right. We’re just focusing on format versioning right now. In a real application, do json.Marshal with error checking and a normal return error.

9. Examples and testing

What it looks like in JSON

It’s useful to see with your own eyes what exactly changes.

Version 1 (old format, field text):

{
  "version": 1,
  "data": [
    { "id": 1, "text": "buy bread", "done": false }
  ]
}

Version 2 (current format, field title):

{
  "version": 2,
  "data": [
    { "id": 1, "title": "buy bread", "done": false }
  ]
}

For the user, it’s almost the same. For your code, these are fundamentally different structs. And versioning lets you support both options honestly and transparently.

Mini process diagram

When you read a versioned document, the mental process should look like this:

flowchart TD
    A[io.Reader] --> B[Decode Envelope]
    B --> C{version}
    C -->|1| D["Unmarshal data as []TaskV1"]
    C -->|2| E["Unmarshal data as []TaskV2"]
    D --> F["map to []Task domain"]
    E --> F["map to []Task domain"]
    F --> G[keep working in code only with domain Task]

The key win: after migration into the domain model, the rest of the code should not need to know what JSON format was used. This sharply reduces the number of places where you can make mistakes.

Format change table

Sometimes it’s useful to keep a very short “format history” right in the code or nearby. Even if you’re a solo developer, in a month you won’t remember why you renamed text to title.

Version data structure Difference
1
[]TaskV1
field text
2
[]TaskV2
field title

This is not so much a “technical table” as it is a way to avoid turning it into a “guess the past” quest.

How to test migration without files

It’s very convenient that our functions work with io.Reader/io.Writer. That means the test scenario can be built entirely in memory via strings.NewReader and bytes.Buffer.

Example: “read the old version and get a domain model”:

package main

import (
	"fmt"
	"strings"
)

func demoReadV1() {
	input := `{"version":1,"data":[{"id":7,"text":"learn go","done":true}]}`
	tasks, err := ReadTasks(strings.NewReader(input))
	fmt.Println(tasks, err) // [{7 learn go true}] <nil>
}

And an example: “write in the current version”:

package main

import (
	"bytes"
	"fmt"
)

func demoWriteV2() {
	var buf bytes.Buffer
	_ = WriteTasks(&buf, []Task{{ID: 1, Title: "sleep", Done: false}})
	fmt.Println(buf.String()) // JSON envelope for version 2 (pretty, with indentation)
}

The advantage of this approach is that you can run the migration a thousand times per second, without depending on disk, paths, permissions, and other “adult life joys”.

10. Common mistakes

Error #1: decode data into the current struct before reading version.
This is the most common slip: you want to “just read tasks”, and your hand reaches for json.Unmarshal straight into []TaskV2. As a result, version 1 either fails, or (worse) is partially read if fields “happen to match”. The correct discipline is: always decode Envelope first, then switch by version.

Error #2: smear migration across business logic.
If if version == 1 starts appearing in “task code” (search, filters, output), you gradually turn the project into a format museum. Migration should happen at the data-read boundary: read → migrate → then only the domain model.

Error #3: try to support many write formats.
Sometimes it feels logical: “since we read v1/v2, let’s also be able to write v1/v2”. Usually it leads to you cornering yourself: options, branches, tests appear, and nobody understands which format is the “main” one. Much simpler: read many, write one current.

Error #4: not treating an unknown version as an error.
If version 999 arrives, that’s not “let’s try to read it as v2”. It’s a signal that the data is from the future or the format is broken. An unknown version must produce a clear error, otherwise you’ll get weird states and broken data with no obvious reason.

Error #5: make the envelope non-strict and silently swallow typos.
If you don’t enable DisallowUnknownFields() at least for the envelope, a typo in a key ("verison") may go unnoticed, and Version becomes 0. Then you’ll get “unsupported version 0” and spend a long time looking for who stole the 1 and the 2. Strict envelope parsing saves a lot of debugging time.

1
Task
Go SELF, level 46, lesson 3
Locked
Envelope version
Envelope version
1
Task
Go SELF, level 46, lesson 3
Locked
Model migration
Model migration
1
Task
Go SELF, level 46, lesson 3
Locked
Reading versions
Reading versions
1
Task
Go SELF, level 46, lesson 3
Locked
Envelope upgrade
Envelope upgrade
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION