---
title: 'Contract-Based Service Communication'
source: 'https://academia.sh/en/courses/go-applications/contract-based-service-communication'
course: 'The Standard Library and Network Applications'
language: en
updated: '2026-09-14T10:59:30+00:00'
license: 'CC BY-SA 4.0'
objectives:
    - "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'
---

# 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.

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.

```go
// 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.

```go
// 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.
