What an API actually is: interfaces, protocols, and the architecture they shape
⚠️ This post is generated by LLM, read with caution.
An API is a promise about a shape: the names, arguments, and return values a client is allowed to depend on, without knowing — or caring — how the other side is built. The service is the thing doing the work; the API is the door the client walks through. That one split, capability separated from contract, is the foundation service-oriented architecture stands on, and it shows up at every scale in computing: a process asking the kernel to write bytes, a Java app asking a database for rows, two machines passing method arguments over a socket, a browser hitting a URL. Every one of those is the same relationship wearing a different coat.
This post walks that relationship from smallest to largest — system calls, JDBC, Java RMI, and a service exposed behind two protocols — and then covers the parts that scale it up: how APIs get described (WSDL, WADL, OpenAPI) and found (UDDI, WS-Discovery, REST-based service stores), and where it lands in SOA and microservices design.
The oldest API you will ever use: the system call
Userspace programs cannot touch hardware or manage memory directly. On Linux they talk to the kernel through one mechanism: a fixed table of entry points, each identified by a number. You pass a number and raw arguments; the kernel does the rest. That table is an API in the strictest sense — it has names (conventionally), signatures, and error codes, and it is stable across implementations of the kernel by design.
The number is architecture-specific, and that matters. On the x86-64 ABI, read is 0 and write is 1; on arm64 they are 63 and 64 — those are the arm64 and amd64 syscall tables as generated into golang.org/x/sys v0.48.0 from the kernel headers, and the run below (this machine is aarch64) confirms the arm64 pair behaves exactly that way:
import ctypes, os
libc = ctypes.CDLL(None, use_errno=True)
# arm64 Linux syscall table (kernel arch/arm64 uapi unistd.h):
# SYS_read = 63, SYS_write = 64 (on x86-64 these are 0 and 1)
SYS_READ, SYS_WRITE = 63, 64
# write(2) straight through glibc's syscall() wrapper: no libc write(),
# no Python buffering -- number + args only.
msg = b"written by a raw write(2) syscall\n"
n = libc.syscall(SYS_WRITE, 1, msg, len(msg))
print("write(2) returned:", n, " errno:", ctypes.get_errno())
# read(2) from a file opened the ordinary way
path = "/tmp/sysex_demo.txt"
with open(path, "w") as f:
f.write("read back by a raw read(2) syscall\n")
fd = os.open(path, os.O_RDONLY)
buf = ctypes.create_string_buffer(256)
n = libc.syscall(SYS_READ, fd, buf, 256)
print("read(2) returned:", n, " errno:", ctypes.get_errno())
print("buffer contents:", buf.value.decode().strip())
os.close(fd)
Running it:
written by a raw write(2) syscall
write(2) returned: 34 errno: 0
read(2) returned: 35 errno: 0
buffer contents: read back by a raw read(2) syscall
Two things to notice. The very first line of output was produced by the raw call — the kernel executed (64, fd=1, buf, 34) as write(2) and returned 34, the exact byte count, which is the kernel’s way of saying “done, that many bytes.” The read returned 35, the file’s length, into a buffer the client provided. And the wrong-number case is worth remembering: when I passed 1 — write’s number on x86-64 — on this arm64 machine, the kernel invoked a different syscall and refused with EINVAL. The number is not the name; the ABI table is the contract.
The same idea as a library: JDBC
Step up one level and the same split appears inside the JVM. JDBC is the java.sql package: client code compiles against java.sql.Connection and friends, and a vendor driver supplies the actual implementation — which is why one app can point at Postgres, MySQL, or H2 and change only a URL and a driver jar, not the code.
You can see the shape of that contract directly from the JDK. javap prints the public surface of a class, and for java.sql.Connection it prints an interface — 52 abstract methods, zero fields the client’s logic would be attached to:
== javap java.sql.Connection (the JDK's own JDBC API, abridged) ==
Compiled from "Connection.java"
public interface java.sql.Connection extends java.sql.Wrapper,java.lang.AutoCloseable {
public abstract java.sql.Statement createStatement() throws java.sql.SQLException;
public abstract java.sql.PreparedStatement prepareStatement(java.lang.String) throws java.sql.SQLException;
public abstract void setAutoCommit(boolean) throws java.sql.SQLException;
public abstract void commit() throws java.sql.SQLException;
public abstract void rollback() throws java.sql.SQLException;
public abstract void close() throws java.sql.SQLException;
... (listing continues)
abstract methods declared by java.sql.Connection: 52
Now shrink the idea to its bones. A Store interface with three methods, two completely different implementations, and a client that knows nothing but the interface:
import java.util.*;
import java.nio.file.*;
// The API: client code compiles against this interface and nothing else.
interface Store {
void put(String key, String value);
String get(String key);
Set<String> keys();
}
// Implementation 1: in memory.
class InMemoryStore implements Store {
private final Map<String, String> m = new LinkedHashMap<>();
public void put(String k, String v) { m.put(k, v); }
public String get(String k) { return m.get(k); }
public Set<String> keys() { return m.keySet(); }
}
// Implementation 2: backed by a text file, "key\tvalue" per line.
class FileBackedStore implements Store {
private final Path file;
FileBackedStore(String path) { file = Path.of(path); }
public void put(String k, String v) {
try { Files.writeString(file, k + "\t" + v + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND); }
catch (java.io.IOException e) { throw new RuntimeException(e); }
}
public String get(String k) {
for (String line : readLines()) {
String[] kv = line.split("\t", 2);
if (kv[0].equals(k)) return kv[1];
}
return null;
}
public Set<String> keys() {
Set<String> ks = new LinkedHashSet<>();
for (String line : readLines()) ks.add(line.split("\t", 2)[0]);
return ks;
}
private List<String> readLines() {
try { return Files.readAllLines(file); }
catch (java.io.IOException e) { return List.of(); }
}
}
public class StoreDemo {
// The client knows only the interface.
static void use(Store s) {
s.put("greeting", "hello through the Store API");
s.put("city", "Lisbon");
System.out.println("client: get(\"greeting\") -> " + s.get("greeting"));
System.out.println("client: keys() -> " + s.keys());
}
public static void main(String[] args) {
System.out.println("-- impl: InMemoryStore --");
use(new InMemoryStore());
System.out.println("-- impl: FileBackedStore(/tmp/store_demo.txt) --");
use(new FileBackedStore("/tmp/store_demo.txt"));
System.out.println("(the exact same client() calls, different implementation)");
}
}
(To be clear about what this is: Store is a teaching stand-in for the JDBC pattern, not JDBC itself. JDBC’s real contract is the java.sql interface set you saw above; a real driver implements it and is plugged in at runtime.)
-- impl: InMemoryStore --
client: get("greeting") -> hello through the Store API
client: keys() -> [greeting, city]
-- impl: FileBackedStore(/tmp/store_demo.txt) --
client: get("greeting") -> hello through the Store API
client: keys() -> [greeting, city]
(the exact same client() calls, different implementation)
The client block is byte-for-byte identical in both runs. The implementation changed from “a map in RAM” to “a file on disk” and nothing in the client noticed — that is the entire value of the split. The client owns what it wants; the implementation owns how it happens.
The same idea across a network: Java RMI
Now move the implementation to another address space — or another machine — and the interface becomes a remote interface. Java RMI does exactly the Store dance with network plumbing underneath: the server exports an implementation of a Remote interface and binds it to a name in a registry; the client looks up the name and calls methods as if they were local. The JDK hands the client a proxy object that translates each call into a network message.
import java.rmi.Remote;
import java.rmi.RemoteException;
public interface Calc extends Remote {
int add(int a, int b) throws RemoteException;
}
import java.rmi.*;
import java.rmi.registry.*;
import java.rmi.server.*;
class CalcImpl extends UnicastRemoteObject implements Calc {
CalcImpl() throws RemoteException { super(0); }
public int add(int a, int b) { return a + b; }
}
public class CalcRmi {
public static void main(String[] args) throws Exception {
Registry registry = LocateRegistry.createRegistry(1099);
CalcImpl impl = new CalcImpl();
Naming.bind("rmi://127.0.0.1:1099/calc", impl);
System.out.println("server: bound object to rmi://127.0.0.1:1099/calc");
Calc client = (Calc) Naming.lookup("rmi://127.0.0.1:1099/calc");
System.out.println("client: looked up, got proxy of type " + client.getClass().getName());
System.out.println("client: calc.add(2, 3) = " + client.add(2, 3));
System.out.println("client: calc.add(-10, 4) = " + client.add(-10, 4));
System.exit(0); // the RMI registry keeps a non-daemon acceptor alive
}
}
server: bound object to rmi://127.0.0.1:1099/calc
client: looked up, got proxy of type jdk.proxy1.$Proxy0
client: calc.add(2, 3) = 5
client: calc.add(-10, 4) = -6
Look at the second line: the client’s object is jdk.proxy1.$Proxy0 — a JDK-generated class, not CalcImpl. The client never touched the implementation; it holds something that merely implements the interface, and every method call is marshaled across the transport. Two more pieces of the API story are hiding in this output:
Naming.lookup("rmi://127.0.0.1:1099/calc")is a discovery call — a name resolved to a live endpoint through a registry, the same pattern UDDI and service stores use at web scale.RemoteExceptionin the interface is the API’s error contract: the client code must handle the failure modes the boundary introduces (network, server death) even though the method body is as simple asa + b.
This demo runs server and client in one JVM for legibility; in a real deployment they’d be separate processes, and the registry would be the standalone rmiregistry process that both point at.
One service, several doors
A service does not have to have one API. The RMI example gave one implementation one name; but nothing stops the same logic from sitting behind a REST-shaped HTTP door and a raw line-protocol TCP door at once. That is the API-as-broker pattern: the service owns the capability, and each exposed protocol is an adapter translating its messages into one shared call.
Here calc() is defined once, and two doors are built over it:
import json, socket, threading, urllib.request
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
# One service, defined once.
def calc(op, a, b):
return {"add": a + b, "mul": a * b}[op]
# Door 1: HTTP + JSON (the REST-shaped door)
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
u = urlparse(self.path)
if u.path != "/calc":
self.send_response(404); self.end_headers(); return
try:
q = parse_qs(u.query)
result = calc(q["op"][0], int(q["a"][0]), int(q["b"][0]))
except Exception:
self.send_response(400); self.send_header("Content-Type", "application/json")
self.end_headers(); self.wfile.write(b'{"error": "expected ?op=add|mul&a=<int>&b=<int>"}')
return
body = json.dumps({"op": q["op"][0], "a": int(q["a"][0]), "b": int(q["b"][0]), "result": result})
self.send_response(200); self.send_header("Content-Type", "application/json")
self.end_headers(); self.wfile.write(body.encode())
def log_message(self, *a): pass
httpd = HTTPServer(("127.0.0.1", 0), Handler)
http_port = httpd.server_address[1]
threading.Thread(target=httpd.serve_forever, daemon=True).start()
# Door 2: a raw TCP line protocol ("add 2 3" -> "5")
tcp = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
tcp.bind(("127.0.0.1", 0)); tcp.listen()
tcp_port = tcp.getsockname()[1]
def serve_tcp():
while True:
conn, _ = tcp.accept()
try:
op, a, b = conn.recv(100).decode().split()
conn.sendall((str(calc(op, int(a), int(b))) + "\n").encode())
finally:
conn.close()
threading.Thread(target=serve_tcp, daemon=True).start()
# Two clients, same service
print(f"server: same calc() service behind two doors: http :{http_port}, tcp :{tcp_port}")
r = urllib.request.urlopen(f"http://127.0.0.1:{http_port}/calc?op=add&a=2&b=3")
print(f"http client: GET /calc?op=add&a=2&b=3 -> HTTP {r.status} {r.read().decode()}")
s = socket.create_connection(("127.0.0.1", tcp_port))
s.sendall(b"add 2 3\n")
print(f"tcp client: send b\"add 2 3\\n\" -> {s.recv(64).decode().strip()!r}")
r = urllib.request.urlopen(f"http://127.0.0.1:{http_port}/calc?op=mul&a=6&b=7")
print(f"http client: GET /calc?op=mul&a=6&b=7 -> HTTP {r.status} {r.read().decode()}")
server: same calc() service behind two doors: http :33445, tcp :41557
http client: GET /calc?op=add&a=2&b=3 -> HTTP 200 {"op": "add", "a": 2, "b": 3, "result": 5}
tcp client: send b"add 2 3\n" -> '5'
http client: GET /calc?op=mul&a=6&b=7 -> HTTP 200 {"op": "mul", "a": 6, "b": 7, "result": 42}
(The two port numbers are ephemeral — both servers bound to port 0 and the OS assigned free ones; each run gets different ones.)
Same result (5) through both doors, different wire format on each: JSON with an HTTP status code on one side, a bare newline-terminated integer on the other. Note how cheap the second door is — because the service is already defined, a protocol door is just message translation. That is the architectural lesson: expose protocols as adapters over one core, not as separate re-implementations of the logic. It also explains why descriptor documents matter — with two doors, something has to tell a client which door is which, and what each one speaks.
Describing the doors: WSDL, WADL, and OpenAPI
Every API has a spoken contract and a written one. The spoken one is what the code enforces; the written one is a machine- or human-readable document of endpoints, operations, and message shapes. The two main paradigms pair up with different description standards:
SOAP is an operation-oriented messaging protocol: every request is an XML envelope with optional Header and a required Body, and messages are sent to fixed endpoints. Its description language is WSDL, whose core vocabulary is types (data), message (payload shapes), portType (the operations a service offers), and port/binding (how and where they’re reached). A minimal sketch of the shape:
<definitions xmlns="http://schemas.xmlsoap.org/wsdl/"
targetNamespace="http://example.org/calc">
<message name="addRequest">
<part name="a" type="xsd:int"/>
<part name="b" type="xsd:int"/>
</message>
<message name="addResponse">
<part name="result" type="xsd:int"/>
</message>
<portType name="Calc">
<operation name="add">
<input message="tns:addRequest"/>
<output message="tns:addResponse"/>
</operation>
</portType>
<service name="CalcService">
<port name="CalcHttp">
<address location="http://example.org/calc"/>
</port>
</service>
</definitions>
REST takes a different stance: instead of named operations, you address resources and use HTTP verbs and status codes — exactly the JSON door in the broker example, where GET /calc?op=add&a=2&b=3 returning 200 and a body is the whole protocol. Its description standards are WADL (a W3C effort describing resources, methods, and representations) and, the one that won in practice, OpenAPI (originating as Swagger): paths, operations, parameters, and response schemas, in YAML or JSON:
openapi: "3.1"
info: {title: Calc Service, version: "1.0"}
paths:
/calc:
get:
parameters:
- {name: op, in: query, schema: {type: string, enum: [add, mul]}}
- {name: a, in: query, schema: {type: integer}}
- {name: b, in: query, schema: {type: integer}}
responses:
"200":
description: computed result
content:
application/json:
schema:
type: object
properties:
result: {type: integer}
These blocks are sketches of each standard’s shape, not tool output. The point is structural: WSDL is a description of operations with messages, WADL/OpenAPI are descriptions of resources with methods — the descriptor mirrors the paradigm it describes. And the payoff of a written contract is that it’s executable: tools generate clients, stubs, and mocks from it. The RMI proxy you saw — generated by the JDK from the Calc interface so the client never needs CalcImpl — is the same idea in miniature.
Finding the doors: discovery
A descriptor tells you what a door looks like; discovery tells you where the doors are. The classic mechanisms, in rough chronological order:
- UDDI — a centralized web registry where publishers list their services (business info plus the technical service pointer) and consumers search by name, category, or taxonomy. It is the “phone book of the SOAP era”: publish once, query centrally.
- WS-Discovery — the local-network variant: instead of querying a registry, a client probes (broadcast/multicast) “who implements service type X?” and endpoints answer with their URL and capabilities. No registry to run; discovery happens at the network edge.
- REST-style store-based search — the pragmatic descendant: an HTTP endpoint you query for services, e.g.
GET /api/services?tag=paymentsreturning a list of descriptor documents. No special protocol, no special registry software — just the web.
You already met this pattern: the RMI client’s Naming.lookup("rmi://127.0.0.1:1099/calc") is name-based discovery through a registry, and the broker example is store-based in spirit — the service’s doors are knowable because they follow a describable, queryable format rather than being tribal knowledge. What changes from UDDI to REST stores is the machinery; the question is constant: given a name or a type, how does a stranger find the endpoint?
SOA and microservices: the split, scaled
Service-oriented architecture is the discipline of building systems out of components whose only stable commitment to each other is an API. The design principles that follow from the split this post has been walking are, in rough priority order:
- The contract is the unit of agreement. Teams align on the interface (names, signatures, error semantics — like
RemoteExceptionin the RMI interface) before anyone touches an implementation. A change that doesn’t touch the API is a local matter; one that does is a negotiation. - Public vs. private is a design decision, not a property. Every service has a public surface you commit to keeping stable and a private surface — internals, helper methods, internal HTTP endpoints — that you may change when nobody is watching. Most API-pain comes from accidentally letting private things leak into the public surface, or from promising stability you never intended.
- Autonomy in exchange for a contract. Because clients depend only on the API, the implementation can be rewritten, re-hosted, or even re-architected (the
Storeswap, the JDBC driver swap) without client changes — and that is what makes independent deployment possible. - Boundaries follow capability, not convenience. Split along a coherent capability (“calculation,” “payments”) rather than cutting a codebase at arbitrary seams; each split should let you predict what the service will do from its API alone.
Microservices are the same split pushed to finer granularity: more, smaller, independently deployable services. SOA is the same contract-first discipline at coarser granularity — and notably, the two paradigms carry different defaults for the door: the SOA/SOAP era standardized on XML envelopes and WSDL (which is why WSDL exists in that vocabulary), while the REST/OpenAPI era standardized on resources and HTTP semantics. The underlying split — service, API, discovery, contract — is identical; only the doors changed.
Takeaway
Strip every example back and it is one relationship: a capability (kernel, database, CalcImpl, calc()) that clients touch only through a contract (read/write, java.sql.Connection, Calc, /calc), located through a discovery mechanism (the ABI table, the driver loader, the RMI registry, a service store). Scale changes the machinery — numbers instead of method names, sockets instead of class files — but never the split. So when you design an API, the questions are the same at every level: how narrow is the door, what does the door promise about errors, and who will you let through it.