01
The Language Barrier
A typical web application is two machines in different places, connected over the internet. The client: the frontend, often a JavaScript app (React, Angular, Vue) running in a browser like Chrome, talks to a server, the backend, which may run on localhost or somewhere remote in AWS, GCP, or Azure. They communicate through some network protocol: HTTP (traditional REST APIs), or gRPC, or WebSocket. This manual assumes HTTP/REST, since it is still the most common.
Here is the catch. The client might be JavaScript and the server might be Rust, and those two languages handle data completely differently:
- JavaScript is dynamic and not compiled, types are loose and decided at runtime.
- Rust is compiled and extremely strict about types.
So when a JavaScript client sends an object like { name: "..." } in the request body, the Rust server cannot natively understand a JavaScript data structure, its own data types are nothing like JavaScript’s. The same problem runs in reverse for the response. Two machines, two incompatible internal worlds, one wire between them.
The barrier. A Rust server can’t natively parse a JavaScript object. They need a universal language for the trip across the network.
02
Serialize & Deserialize
The solution: both sides agree on a single, common, standard format for data on the wire. Each machine converts into that format before sending, and out of it after receiving.
Serialization
Converting native data (a JavaScript object, a Rust struct, a Go struct, a Python object) into the common standard format before sending it over the network (or storing it).
Deserialization
The exact reverse: taking data in the common format and parsing it back into the machine’s own native data type, so it can run its business logic.
Two directions, one format. Serialize = native -> common. Deserialize = common -> native. The format in the middle is what travels.
Language-agnostic & domain-agnostic
Because the format is neutral, any machine can talk to any other machine regardless of the languages or technologies underneath. “Language-agnostic” = it doesn’t care whether you’re JS or Rust. “Domain-agnostic” = it doesn’t care which environment or technology stack you run.
03
Agreeing on a Common Standard
If you were handed this problem cold, two machines in different locations, connected over the internet, that must exchange data in a way that is language-agnostic, the obvious solution is to define a common standard.
A “standard” / “format”
Just a fancy word for an agreed set of rules describing how data must be written. Both client and server promise: “we will send and receive data shaped like this.”
With that agreement in place, the client converts its native logic (say, from JavaScript) into the standard before sending; the server reads the standard and converts it into its native type (say, a Rust struct); it does its work, converts the response back into the standard, and sends it; and the client parses it back into JavaScript. That round-trip is serialization and deserialization. In one line: converting data to and from a common format during transmission (or storage), so it is understandable across languages and domains.
04
Text-based vs Binary Formats
There are many serialization standards, but they fall into two families.
Two families. Text-based formats are readable by humans; binary formats compile to raw bytes for compact, efficient transmission.
- Text-based formats: human-readable.
JSON,YAML, andXML. You can open the payload and read it. - Binary formats: compiled into raw binary for highly efficient, compact transmission.
Protobuf(Protocol Buffers) andAvroare the popular ones. Not meant to be read by eye.
YAML and XML are still used for serialization, but not so often for HTTP communication. For traditional HTTP REST APIs, the default choice is JSON.
05
JSON: the Industry Standard
For traditional HTTP REST API communication, JSON is the most popular serialization standard, used something like 80% of the time.
JSON
JavaScript Object Notation. Named because it looks and behaves much like a JavaScript object, but despite the name it is not limited to JavaScript and is used everywhere, across every language.
Why it won
- Human-readable. JSON was designed to be read by people; you can glance at a payload and understand it. That is one of its strongest selling points.
- Fundamental data types. Its value types map cleanly onto concepts every programmer already knows.
Where you’ll meet it
- HTTP transmission: the request/response bodies of REST APIs (the focus here).
- Logging: a very popular choice for application and server logs at runtime.
- Configuration files: app and tool config.
06
JSON Syntax Rules
JSON has very few rules, that is the whole point. Here is a typical object, annotated with every rule the source calls out:
Every rule in one object. Braces wrap it; keys are quoted strings; values are simple types or nested objects.
The rules, spelled out
- A JSON object starts with
{and ends with}. - Keys must be strings wrapped in double quotes: e.g.
"name":. No other data type can be a key. (Double quotes, always, not single.) - Values are limited to fundamental types: a string, a number, a boolean, an array, or another nested object.
- A nested object follows the exact same rules recursively: braces, quoted string keys, and values of those same fundamental types. JSON can nest as deep as you like.
07
The OSI Mental Model
When data crosses the internet it passes through many network layers, the OSI model. You do not need to master IP packets and data frames to be a backend engineer; a high-level picture is enough. The two ends of the stack are the application layer (top) and the physical layer (bottom); there are several layers in between, and the same stack exists on both client and server.
The abstraction. Your data starts and ends as JSON at the application layer. In between it becomes frames, packets, and bits, but that is not your concern.
The backend engineer’s mental model
You only focus on the application layer. Assume your client serializes data into JSON and sends it. Under the hood the network turns that JSON into data frames -> IP packets -> physical bits (0s and 1s) sent as electrical or optical signals. Before your server ever touches the data, the network reassembles those bits back into the exact same JSON the client sent. You never deal with the intermediate conversions, only the JSON.
08
The End-to-End Workflow
Putting every piece together, here is the complete round-trip in a real request, the phenomenon that, as a whole, is serialization & deserialization.
The full round-trip. Serialize on each side before sending; deserialize on each side after receiving. Four conversions per request/response cycle.
In words: the client gathers input, serializes it to a JSON string in the request body, and ships it; the bits cross the internet; the server deserializes the JSON into its native type, runs business logic (e.g. saves to a database), serializes its response back to JSON, and sends it; the client deserializes that JSON into a JavaScript object and updates the UI. That whole flow is what we call serialization and deserialization, a phenomenon you mostly just need to know exists; it has very few concepts of its own.
09
Serialization in Code
Every language has a JSON codec, and each one expresses the same OOP architecture in its own way. Below is the same serializer in five languages, pick a tab. The pillars are tagged OOP in the comments:
- Go: the standard
encoding/jsonpackage,Marshal= serialize,Unmarshal= deserialize. Interfaces (abstraction + polymorphism), unexported fields + struct tags (encapsulation), and embedding (composition, Go’s inheritance). - Python: the built-in
jsonmodule,json.dumps= serialize (dump to string),json.loads= deserialize (load from string). An ABC contract, inheritance, polymorphism, and a private attribute. - JavaScript: the built-in
JSONobject,JSON.stringify= serialize,JSON.parse= deserialize. One twist worth noticing: a#privatefield is invisible toJSON.stringify, so the language itself keeps the secret off the wire, andtoJSON()is the hook that decides which keys leave the object, JavaScript’s answer to Go’s struct tags. - TypeScript: the same
JSONobject with types on both sides of the wire:toJSON()returns a declaredUserJSONshape, so a misspelled key fails to compile, anddeserialize()returnsunknown, so incoming data has to pass a type guard before you can touch it. - Java: no JSON in the standard library, so the de facto standard Jackson,
writeValueAsString= serialize,readValue= deserialize. Aninterfacecontract, an abstract base class,privatefields, and annotations (@JsonProperty,@JsonIgnore) that play the role of Go’s struct tags.
package main
import ("encoding/json"; "fmt"; "time")
// ABSTRACTION: Serializer names a capability, "turn data into
// bytes and back", without binding to a concrete format. Code
// depends on this contract, so JSON could be swapped for another
// format with zero changes to callers.
type Serializer interface {
Serialize(v any) ([]byte, error) // native -> common format
Deserialize(data []byte, v any) error // common format -> native
}
// POLYMORPHISM: JSONSerializer implements Serializer. Any other
// format (YAML, Protobuf) could implement the same interface and
// be used interchangeably through a Serializer variable.
type JSONSerializer struct{}
func (JSONSerializer) Serialize(v any) ([]byte, error) {
return json.Marshal(v) // SERIALIZE: struct -> JSON bytes
}
func (JSONSerializer) Deserialize(data []byte, v any) error {
return json.Unmarshal(data, v) // DESERIALIZE: JSON bytes -> struct
}
// "INHERITANCE" via COMPOSITION: BaseModel holds shared fields;
// embedding it into User reuses them (and their json tags).
type BaseModel struct {
ID int `json:"id"` // tag = the JSON key name
CreatedAt time.Time `json:"created_at"`
}
// ENCAPSULATION: an exported struct whose JSON shape is controlled
// by tags. "password" is unexported (lowercase) -> private AND
// invisible to the JSON encoder, so it never leaks over the wire.
type User struct {
BaseModel // embedded -> inherits ID, CreatedAt
Name string `json:"name"`
Active bool `json:"active"`
Address Address `json:"address"` // nested object
password string // unexported: hidden from JSON output
}
type Address struct {
Country string `json:"country"`
Phone int `json:"phone"`
}
func main() {
var codec Serializer = JSONSerializer{} // program to the interface
u := User{
BaseModel: BaseModel{ID: 1, CreatedAt: time.Now()},
Name: "Ada", Active: true,
Address: Address{Country: "India", Phone: 123456},
}
// SERIALIZE, native Go struct into the common JSON format
out, _ := codec.Serialize(u)
fmt.Println(string(out))
// {"id":1,"created_at":"...","name":"Ada","active":true,
// "address":{"country":"India","phone":123456}}
// DESERIALIZE, JSON received over HTTP back into a Go struct
incoming := []byte(`{"name":"Lin","address":{"country":"IN","phone":42}}`)
var back User
codec.Deserialize(incoming, &back)
fmt.Println(back.Name, back.Address.Country) // Lin IN
}import json
from abc import ABC, abstractmethod
from dataclasses import dataclass, asdict, field
# ABSTRACTION: Serializer is an Abstract Base Class. The
# @abstractmethod forces subclasses to implement both directions.
# You cannot instantiate Serializer itself, it is a pure contract.
class Serializer(ABC):
@abstractmethod
def serialize(self, obj) -> str: ... # native -> common
@abstractmethod
def deserialize(self, data: str): ... # common -> native
# INHERITANCE + POLYMORPHISM: JSONSerializer IS-A Serializer and
# overrides both methods. A YamlSerializer could subclass the same
# ABC and be dropped in wherever a Serializer is expected.
class JSONSerializer(Serializer):
def serialize(self, obj) -> str:
return json.dumps(obj) # SERIALIZE: dict -> JSON text
def deserialize(self, data: str):
return json.loads(data) # DESERIALIZE: JSON text -> dict
# INHERITANCE: BaseModel is a shared parent (id, timestamps).
@dataclass
class BaseModel:
id: int = 0
@dataclass
class Address:
country: str
phone: int # nested object
# ENCAPSULATION: __password is name-mangled (-> _User__password),
# effectively private, and to_dict() chooses what leaves the
# object, the secret never appears in the serialized output.
@dataclass
class User(BaseModel): # IS-A BaseModel
name: str = ""
active: bool = True
address: Address | None = None
__password: str = "" # private; excluded below
def to_dict(self) -> dict:
d = asdict(self)
d.pop("_User__password", None) # keep the secret out
return d
if __name__ == "__main__":
codec: Serializer = JSONSerializer() # program to the contract
user = User(id=1, name="Ada",
address=Address("India", 123456))
# SERIALIZE, native object into the common JSON format
payload = codec.serialize(user.to_dict())
print(payload)
# {"id": 1, "name": "Ada", "active": true,
# "address": {"country": "India", "phone": 123456}}
# DESERIALIZE, JSON received over HTTP back into native data
incoming = '{"name": "Lin", "address": {"country": "IN", "phone": 42}}'
data = codec.deserialize(incoming)
print(data["name"], data["address"]["country"]) # Lin IN// ABSTRACTION: JavaScript has no interface keyword, so the contract is a base
// class whose methods refuse to run. Callers depend only on this shape, so
// JSON could be swapped for another format without touching them.
class Serializer {
serialize(value) { throw new Error("serialize() must be implemented"); } // native -> common
deserialize(data) { throw new Error("deserialize() must be implemented"); } // common -> native
}
// POLYMORPHISM: JsonSerializer implements the contract. Any other format
// (YAML, Protobuf) can extend the same base and be swapped in unchanged.
class JsonSerializer extends Serializer {
serialize(value) {
return JSON.stringify(value); // SERIALIZE: object -> JSON text
}
deserialize(data) {
return JSON.parse(data); // DESERIALIZE: JSON text -> object
}
}
// "INHERITANCE": BaseModel holds the shared fields; User extends it and
// reuses them, the counterpart of Go's struct embedding.
class BaseModel {
constructor(id = 0, createdAt = new Date()) {
this.id = id;
this.createdAt = createdAt;
}
}
class Address {
constructor(country, phone) {
this.country = country;
this.phone = phone; // nested object
}
}
// ENCAPSULATION: #password is private at RUNTIME, so it is both unreachable
// from outside AND invisible to JSON.stringify, the secret never reaches the
// wire. toJSON() is the hook stringify calls: it is JavaScript's answer to
// Go's struct tags, deciding exactly which keys leave the object.
class User extends BaseModel {
#password;
constructor({ id, name, active = true, address = null, password = "" }) {
super(id);
this.name = name;
this.active = active;
this.address = address;
this.#password = password;
}
toJSON() {
return {
id: this.id,
created_at: this.createdAt, // rename on the way out, like a json tag
name: this.name,
active: this.active,
address: this.address,
};
}
}
const codec = new JsonSerializer(); // program to the contract
const user = new User({ id: 1, name: "Ada", address: new Address("India", 123456) });
// SERIALIZE, native object into the common JSON format
const payload = codec.serialize(user);
console.log(payload);
// {"id":1,"created_at":"...","name":"Ada","active":true,
// "address":{"country":"India","phone":123456}}
// DESERIALIZE, JSON received over HTTP back into native data
const incoming = '{"name":"Lin","address":{"country":"IN","phone":42}}';
const data = codec.deserialize(incoming);
console.log(data.name, data.address.country); // Lin IN// ABSTRACTION: TypeScript has a real interface keyword, so the contract is a
// type the compiler checks. Callers depend only on this shape, so JSON could
// be swapped for another format without touching them.
interface Serializer {
serialize(value: unknown): string; // native -> common
deserialize(data: string): unknown; // common -> native, untrusted until checked
}
// POLYMORPHISM: JsonSerializer implements the contract. Any other format
// (YAML, Protobuf) can implement the same interface and be swapped in unchanged.
class JsonSerializer implements Serializer {
serialize(value: unknown): string {
return JSON.stringify(value); // SERIALIZE: object -> JSON text
}
deserialize(data: string): unknown {
return JSON.parse(data); // DESERIALIZE: JSON text -> unknown
}
}
// "INHERITANCE": BaseModel holds the shared fields; User extends it and
// reuses them, the counterpart of Go's struct embedding.
class BaseModel {
id: number;
createdAt: Date;
constructor(id = 0, createdAt = new Date()) {
this.id = id;
this.createdAt = createdAt;
}
}
interface Address {
country: string;
phone: number; // nested object
}
// The exact keys that leave a User. With the wire shape declared as a type,
// a misspelled or forgotten key in toJSON() is a compile error.
interface UserJSON {
id: number;
created_at: Date; // rename on the way out, like a json tag
name: string;
active: boolean;
address: Address | null;
}
interface UserInit {
id: number;
name: string;
active?: boolean;
address?: Address | null;
password?: string;
}
// ENCAPSULATION: #password is private at RUNTIME, so it is both unreachable
// from outside AND invisible to JSON.stringify. TypeScript's `private` keyword
// is not enough on its own: it only hides the field from the compiler, and
// JSON.stringify would still write it out. toJSON() decides which keys leave.
class User extends BaseModel {
name: string;
active: boolean;
address: Address | null;
#password: string;
constructor({ id, name, active = true, address = null, password = "" }: UserInit) {
super(id);
this.name = name;
this.active = active;
this.address = address;
this.#password = password;
}
toJSON(): UserJSON {
return {
id: this.id,
created_at: this.createdAt,
name: this.name,
active: this.active,
address: this.address,
};
}
}
// What we EXPECT a client to send. JSON.parse can't promise that shape, so a
// type guard checks it at runtime before the compiler lets us use the data.
interface IncomingUser {
name: string;
address: Address;
}
function isIncomingUser(value: unknown): value is IncomingUser {
const v = value as Partial<IncomingUser> | null;
return typeof v?.name === "string"
&& typeof v.address?.country === "string"
&& typeof v.address?.phone === "number";
}
const codec: Serializer = new JsonSerializer(); // program to the contract
const user = new User({ id: 1, name: "Ada", address: { country: "India", phone: 123456 } });
// SERIALIZE, native object into the common JSON format
const payload = codec.serialize(user);
console.log(payload);
// {"id":1,"created_at":"...","name":"Ada","active":true,
// "address":{"country":"India","phone":123456}}
// DESERIALIZE, JSON received over HTTP back into native data
const incoming = '{"name":"Lin","address":{"country":"IN","phone":42}}';
const data = codec.deserialize(incoming); // unknown: data.name won't compile yet
if (isIncomingUser(data)) {
console.log(data.name, data.address.country); // Lin IN
}// Java has no JSON in its standard library; Jackson is the de facto standard.
// Dependency: tools.jackson.core:jackson-databind (Jackson 3)
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.annotation.JsonPropertyOrder;
import java.time.Instant;
import tools.jackson.databind.json.JsonMapper;
// ABSTRACTION: Serializer is a real interface, a pure contract. Callers
// depend only on it, so JSON could be swapped for another format without
// touching them.
interface Serializer {
String serialize(Object value); // native -> common
<T> T deserialize(String data, Class<T> type); // common -> native
}
// POLYMORPHISM: JsonSerializer implements the contract. A YamlSerializer
// could implement the same interface and be swapped in unchanged.
class JsonSerializer implements Serializer {
private final JsonMapper mapper = new JsonMapper(); // Jackson's JSON codec
@Override
public String serialize(Object value) {
return mapper.writeValueAsString(value); // SERIALIZE: object -> JSON text
}
@Override
public <T> T deserialize(String data, Class<T> type) {
return mapper.readValue(data, type); // DESERIALIZE: JSON text -> object
}
}
// "INHERITANCE": BaseModel holds the shared fields; User extends it and
// reuses them, the counterpart of Go's struct embedding.
abstract class BaseModel {
@JsonProperty("id")
private final long id;
@JsonProperty("created_at") // rename on the way out, like a json tag
private final Instant createdAt;
protected BaseModel(long id) {
this.id = id;
this.createdAt = Instant.now();
}
}
record Address(String country, int phone) {} // nested object; records map to JSON as-is
// ENCAPSULATION: every field is private. Annotations are Java's answer to
// Go's struct tags: @JsonProperty lets a field onto the wire, @JsonIgnore
// keeps password off it for good, even if someone adds a getter later.
@JsonPropertyOrder({"id", "created_at", "name", "active", "address"})
class User extends BaseModel {
@JsonProperty private final String name;
@JsonProperty private final boolean active;
@JsonProperty private final Address address;
@JsonIgnore private final String password;
User(long id, String name, Address address, String password) {
super(id);
this.name = name;
this.active = true;
this.address = address;
this.password = password;
}
}
// What we EXPECT a client to send. readValue binds the JSON straight into
// this typed record, and a value of the wrong type (text where phone expects
// a number) throws instead of slipping through.
record IncomingUser(String name, Address address) {}
public class Serialize {
public static void main(String[] args) {
Serializer codec = new JsonSerializer(); // program to the contract
User user = new User(1, "Ada", new Address("India", 123456), "s3cret");
// SERIALIZE, native object into the common JSON format
String payload = codec.serialize(user);
System.out.println(payload);
// {"id":1,"created_at":"...","name":"Ada","active":true,
// "address":{"country":"India","phone":123456}}
// DESERIALIZE, JSON received over HTTP back into a typed record
String incoming = """
{"name":"Lin","address":{"country":"IN","phone":42}}""";
IncomingUser data = codec.deserialize(incoming, IncomingUser.class);
System.out.println(data.name() + " " + data.address().country()); // Lin IN
}
}10
Glossary
Every term from the source, in one place.
| Term | Meaning |
|---|---|
| Serialization | Converting native data (object/struct) into a common standard format to send or store. |
| Deserialization | The reverse, parsing the common format back into the machine’s native data type. |
| Client / frontend | The app sending requests, often a JavaScript app in a browser. |
| Server / backend | The machine receiving requests and running business logic (e.g. a Rust app). |
| Native data type | A language’s own internal representation, a JS object, a Rust/Go struct, a Python object. |
| Parse | To read a format and turn it into something the machine can use. |
| Common standard / format | An agreed set of rules for how data is written, so both sides understand it. |
| Language-agnostic | Works regardless of which programming language each side uses. |
| Domain-agnostic | Works regardless of the environment / technology stack on each side. |
| Text-based format | Human-readable serialization, JSON, YAML, XML. |
| Binary format | Compact, efficient, non-readable serialization, Protobuf, Avro. |
| JSON | JavaScript Object Notation; the ~80% choice for HTTP REST; human-readable; not JS-only. |
| Key / value | JSON keys are quoted strings; values are string, number, boolean, array, or nested object. |
| Nested object | A JSON object inside another, following the same rules recursively. |
| OSI model | The layered model of network communication, from application layer down to physical layer. |
| Application layer | The top layer, where you, the backend engineer, work in JSON. |
| Physical layer | The bottom layer, raw bits (0s and 1s) as electrical/optical signals. |
| Data frames / IP packets | Intermediate forms the data takes between application and physical layers. |
Backend from First Principles / Chapter 03 / Serialization. Code targets Go 1.22+, Python 3.11+, Node.js 20+, TypeScript 5+ and Java 17+.