Skip to content
academia.sh

Lesson 05 / 12

Contract-Based Service Communication

The source of two independent processes is generated with text/template from the same shared schema (a list of field names, field types, JSON tags) and compiled and run separately. While the schema is shared, all three fields preserve their type — the single line, among the course's thirteen boundary crossings, that falls into the preserved class. Once the schema diverges (if the receiving side's generator is out of date), the crossing breaks silently: two of the three fields preserve their type, one is lost without ever producing an error.

Published

In this lesson

  • Generates two independent processes' source from the same shared schema with text/template and compiles them separately
  • Proves by running it that all three fields of a value crossing the boundary preserve their type while the schema is shared
  • Measures, by showing which field silently disappears, that the crossing completes without error but incompletely once the schema diverges on the two sides
Contents

The previous lesson showed that the text a template produces is not expected to be read back by anyone: the output was only text, and there was no side that would read that text back and bind it to a type. This lesson starts from the exact opposite end. Two independent programs (two separate processes, two separate compilations) will pass a value between them, and this time the same schema will stand on both sides of the boundary. The course’s question does not change — what happens to a value’s type information as it crosses a boundary? — but this lesson builds the single line, among the thirteen boundary crossings, that falls into the preserved class.

A schema here is a description both sides agree on over a list of fields: each field’s name, Go type, and JSON tag. In earlier lessons the schema always stood on one side of the boundary — the target type in the receiver’s hand for JSON, the rule the vet tool knew in the command family. In this lesson the schema is held in a third place belonging to neither side of the boundary (as a Go slice, in the program’s own memory), and the same schema is used to generate both sides’ source. The generation is done with the standard library’s own template package; no third-party tool, protocol, or data format comes into play.

It would also have been possible to write both sides’ source by hand, separately — two Record types, in two files, with the same field names. But in that case being “the same” would turn into a matter of discipline: one could change while the other was forgotten, and this forgetting would not be visible in the source code itself. Holding the schema in a single place and generating two sources from it does not remove this risk — as will be seen shortly, if the generator itself is not updated, the same problem comes back — but it gathers the risk into a single point: it is no longer two hand-written sources staying in sync that matters, it is enough for a single schema to be fed into both generation calls at the same time.

Generating Both Sides’ Source from the Same Schema

ND26. The procedures below take a schema (a FieldSchema slice) and run a single template that can produce two different sources depending on the Role field: the "sender" role produces a program that turns a value into JSON and writes it to standard output, the "receiver" role produces a program that reads from standard input and decodes it into the same struct. run runs both generated sources, each in a separate temporary module, with go run ., as a separate process — these two “sides” never see each other’s source at all, they exchange bytes only through standard input and output.

// contract.go — the procedures that run a shared schema to generate both sides' source
package main

import (
	"bytes"
	"os"
	"os/exec"
	"path/filepath"
	"text/template"
)

type FieldSchema struct {
	Name    string
	Type    string
	Tag     string
	Example string
}

type TemplateData struct {
	Fields []FieldSchema
	Role   string
}

func generate(tmpl *template.Template, data TemplateData) string {
	var buf bytes.Buffer
	tmpl.Execute(&buf, data)
	return buf.String()
}

func run(source string, input []byte) string {
	dir, _ := os.MkdirTemp("", "contract")
	defer os.RemoveAll(dir)
	os.WriteFile(filepath.Join(dir, "go.mod"), []byte("module side\n\ngo 1.24\n"), 0o644)
	os.WriteFile(filepath.Join(dir, "main.go"), []byte(source), 0o644)

	cmd := exec.Command("go", "run", ".")
	cmd.Dir = dir
	cmd.Env = append(os.Environ(), "GOTOOLCHAIN=local")
	if input != nil {
		cmd.Stdin = bytes.NewReader(input)
	}
	output, _ := cmd.Output()
	return string(output)
}

generate writes the template into a bytes.Buffer and returns it as a string — exactly the step the previous lesson measured, used here to produce a piece of source code. run is the same pattern established in the Go Fundamentals course’s Go Command Family lesson: writing the source to a temporary module and calling the compiler (here through go run) from inside the program. The difference is here — there, a single source was being formatted or checked; here, two different sources, both derived from the same schema, run in two separate processes unaware of each other.

The template itself stands in a second block; the template text produces one struct field for every field in the schema slice, and depending on the role, either an encoding or a decoding body. The import list also branches on role: "fmt" and "io" are only added in the receiver role, because the sender’s body never uses either of them. This distinction is not arbitrary — that an unused import stops compilation in Go was a rule established in the Go Fundamentals course; the template does not know this rule, but the generator (that is, this lesson) does, and while producing two different source texts, it ensures both stay independently compilable with an {{if}} block that branches on role.

// measurement.go — measuring the contract-based crossing with a shared schema and a diverged schema
package main

import (
	"fmt"
	"strings"
	"text/template"
)

const backtick = "`"

var templateText = `package main

import (
	"encoding/json"
{{if eq .Role "receiver"}}	"fmt"
	"io"
{{end}}	"os"
)

type Record struct {
{{range .Fields}}	{{.Name}} {{.Type}} ` + backtick + `json:"{{.Tag}}"` + backtick + `
{{end}}}

func main() {
{{if eq .Role "sender"}}	r := Record{
{{range .Fields}}		{{.Name}}: {{.Example}},
{{end}}	}
	data, _ := json.Marshal(r)
	os.Stdout.Write(data)
{{else}}	data, _ := io.ReadAll(os.Stdin)
	var r Record
	json.Unmarshal(data, &r)
{{range .Fields}}	fmt.Printf("%s type=%T value=%v\n", "{{.Name}}", r.{{.Name}}, r.{{.Name}})
{{end}}{{end}}}
`

func main() {
	tmpl := template.Must(template.New("side").Parse(templateText))

	sharedSchema := []FieldSchema{
		{Name: "Name", Type: "string", Tag: "name", Example: `"bolt"`},
		{Name: "Quantity", Type: "int", Tag: "quantity", Example: "12"},
		{Name: "Price", Type: "float64", Tag: "price", Example: "19.90"},
	}

	senderSource := generate(tmpl, TemplateData{Fields: sharedSchema, Role: "sender"})
	fmt.Println("-- generated sender source --")
	fmt.Println(senderSource)

	transferredBytes := run(senderSource, nil)

	receiverSource := generate(tmpl, TemplateData{Fields: sharedSchema, Role: "receiver"})
	receiverOutput := run(receiverSource, []byte(transferredBytes))
	fmt.Println("-- schema shared: the receiver reads all three fields --")
	fmt.Print(receiverOutput)
	sharedFieldCount := strings.Count(strings.TrimRight(receiverOutput, "\n"), "\n") + 1

	staleSchema := sharedSchema[:2]
	staleReceiverSource := generate(tmpl, TemplateData{Fields: staleSchema, Role: "receiver"})
	staleReceiverOutput := run(staleReceiverSource, []byte(transferredBytes))
	fmt.Println()
	fmt.Println("-- schema diverged: the receiver side's generator schema is stale --")
	fmt.Print(staleReceiverOutput)
	staleFieldCount := strings.Count(strings.TrimRight(staleReceiverOutput, "\n"), "\n") + 1

	fmt.Println()
	fmt.Printf("schema shared: %d/%d fields' types preserved\n", sharedFieldCount, len(sharedSchema))
	fmt.Printf("schema diverged: %d/%d fields' types preserved, %d field silently lost\n",
		staleFieldCount, len(sharedSchema), len(sharedSchema)-staleFieldCount)
}
-- generated sender source --
package main

import (
	"encoding/json"
	"os"
)

type Record struct {
	Name string `json:"name"`
	Quantity int `json:"quantity"`
	Price float64 `json:"price"`
}

func main() {
	r := Record{
		Name: "bolt",
		Quantity: 12,
		Price: 19.90,
	}
	data, _ := json.Marshal(r)
	os.Stdout.Write(data)
}

-- schema shared: the receiver reads all three fields --
Name type=string value=bolt
Quantity type=int value=12
Price type=float64 value=19.9

-- schema diverged: the receiver side's generator schema is stale --
Name type=string value=bolt
Quantity type=int value=12

schema shared: 3/3 fields' types preserved
schema diverged: 2/3 fields' types preserved, 1 field silently lost

ND27. sharedSchema is a three-element Go slice, defined exactly once inside the main function. Both senderSource and receiverSource are generated from this same variable — sharedSchema is passed to both generate calls, there is no other copy, no other hand-written list. This satisfies the rule the BOUNDARY shared definition sets: if a lesson is going to write a preserved line, it also has to show how the schema reached both sides — the way shown here is that the same memory value becomes the input to two separate generation calls. The generated sources are then completely independent of each other: they are compiled and run as separate processes, in separate temporary directories, with separate go.mod files. The sender side’s source never sees the receiver side’s source at all, the two talk only through transferredBytes, as a plain byte sequence.

Running as separate processes is not a detail here, it is the body of the measurement. If sender and receiver shared a single Record type imported from the same Go package, type preservation would be the compiler’s own guarantee — the two sides would already be looking at the same memory layout, and this lesson would have assumed from the start the very thing it wants to measure. Talking only through JSON bytes between two separately compiled, separately running binaries is what makes the boundary a real boundary: the two sides agree on the same type not because the schema is shared, but because each of them independently generated the fact that the schema is shared.

Blind spot. This measurement’s preserved class is built on the assumption that the schema really reaches both sides — and this lesson models that reaching as two generation calls reading the same Go slice. In production, both sides sharing the same schema usually requires a distribution step (one side’s updated schema somehow reaching the other); this lesson does not measure that distribution step itself, it only measures what is gained when distribution succeeds and what is lost when it fails (the section below).

While the Schema Is Shared, All Three Fields Preserve Their Type

ND28. When the receiver source is generated from sharedSchema, it decodes the JSON the sender wrote into its own Record type and reads all three fields with their correct type: Name comes back as a string, Quantity as an int, Price as a float64 — identical to the types on the source side. This line is the course’s single preserved line in the full distribution of thirteen boundary crossings (1 preserved, 5 rebuilt, 7 lost; the number built in Lesson 03). The rebuilt lines in earlier lessons also used a schema (a struct tag, a target type, a stamp in a format string), but that schema always stood on one side of the boundary, and the other side knew its own schema on its own, from a separate source. Here both sides are the product of the same schema — this difference is the entire distance between “rebuilt” and “preserved.”

ND29. The sender’s and receiver’s source code differ from each other: one calls json.Marshal, the other json.Unmarshal; one writes to os.Stdout, the other reads from os.Stdin. But their Record types carry byte-for-byte identical field names and identical JSON tags, because both ran the {{range .Fields}} loop over the same sharedSchema slice. The code itself is not the same, but the schema that generated the code is — and JSON’s field mapping (built over a tag’s string counterpart) works only as long as these two tags are the same. Because the schema comes from a single source, the tags being the same is not a coincidence, it is the direct result of how they were generated.

When the Schema Diverges: A Silent Loss

ND30. staleSchema is sharedSchema‘s first two elements — there is no Price field. This models a situation where the receiving side’s generator was not updated while the sending side stayed current: the two sides no longer share the same schema. staleReceiverSource is generated from this incomplete schema, and its own Record type has only the Name and Quantity fields. This source compiles — the absence of Price is no problem at all in Go’s type system, because a missing field never demands being part of another type — and it runs, the json.Unmarshal call returns no error.

ND31. transferredBytes carries all three fields ({"name":"bolt","quantity":12,"price":19.9}), but the struct staleReceiverSource decodes into can only read two of them. The price key sits in the JSON text, json.Unmarshal sees it, and because it has no counterpart in the target struct it silently skips it — the exact same mechanism as the JSON lesson’s observation, two shares back, that “a field with no counterpart on the receiver drops silently,” with a single difference here: the loss is born not from a programming error but from the two sides’ schemas diverging from each other. The measurement’s last line gives this in numbers: 3/3 while the schema is shared, 2/3 once the schema diverges, with no warning, no error at all about the lost field.

The importance of direction shows up here: the side that is missing is the receiver. As long as the sending side’s source stays current, that is, as long as it still sends Price, no sign of the fault appears on the sending side at all — senderSource never changed in this lesson; the run(senderSource, nil) call produced the same bytes in both scenarios. The problem is only in the source the receiving side generates, and in the fact that the side generating that source (the generator using its own staleSchema) was not updated. If the roles were reversed — the sender generated with the stale schema, the receiver with the current one — the result would differ: the Price field the receiver expects would not be present in the JSON at all, and the struct would fill this field with its own zero value (0), because encoding/json silently ignores a missing key exactly like it ignores an unknown one. Both directions fall into the same class (lost), but where the loss appears changes: in one direction the field is not in the struct the receiver reads at all, in the other the field is there but always zero.

ND32. This proves the whole claim of contract-based communication in reverse: the preservation does not come from the schema itself, it comes from the schema’s distribution. As long as the same schema reaches both sides at the same time, the crossing stays in the preserved class; if one side is updated and the other is left behind, the crossing falls back into the “lost” class without producing any error. A single shared schema source (the sharedSchema variable in this lesson) is preservation’s precondition; the schema itself is never a guarantee, only a possibility that depends on how in sync the two sides stay.

This result’s relationship to the course’s general claim is not indirect, it is direct: in five of the thirteen boundary crossings type was “rebuilt,” and in each of them the side that rebuilt it did so from a schema in the other side’s hand — but that schema was always the other side’s own schema, and there was no mechanism confirming it was the same as the sending side’s schema. This lesson revealed the difference between “rebuilding” and “preservation” by reducing those two schemas to a single one: rebuilding rested on the other side’s guess turning out to be correct; preservation rests on there being no need for a guess at all.

Summary

  • The source of two independent processes was generated with text/template from the same shared schema slice and compiled and run separately in temporary modules; the sides never saw each other’s source, they only exchanged bytes.
  • While the schema is shared, all three fields preserve their type: Name, Quantity, Price — the types on the source and target sides matched exactly. This is the single line, among the course’s thirteen boundary crossings, that falls into the preserved class.
  • Preservation was shown by passing the schema into the generate call from the same value; the rule that a “preserved” line cannot be written without evidence was fully satisfied here.
  • When the schema diverges on the two sides (a field is generated missing), the source still compiles, the program still runs, but the missing field drops without producing any error — a silent loss from 3/3 to 2/3.
  • Contract-based communication’s preservation is not a guarantee, it is an outcome that depends on the two sides’ schemas staying in sync.

Next Step

The sharing measured in this lesson was not about what the schema was, but about when it was set up: the sending side’s source was generated and run, then the receiving side’s source started in a separate process and read the byte the sender had left behind — the two sides never ran at the same time, and there was a single exchange between them: a write that looks like a request, a read that looks like a response. The next lesson removes this assumption: when a connection lasts a long time, when many crossings flow one after another over the same connection instead of a single exchange, and when one side can push data without a request coming from the other side, what changes about the boundary?

Framing comes into play as the single piece of information that carries where a message starts and ends — the single mechanism that says where a stream ends one message and begins the next. But this lesson has shown exactly that drawing a boundary correctly is not the same thing as knowing the type of the value inside it: framing says where a message ends, not whether fields like Name, Quantity, Price stand inside that message. Answering that question will again require a schema — a schema of the kind this lesson measured.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ ↵ Esc navigate · open · close