Client-Server & API-Centric Architecture

Software Architecture Fundamentals

Chapter 8 · Client-Server & API-Centric Architecture

"REST" gets used loosely to mean "an HTTP API with GET/POST/PUT/DELETE" — but its actual defining architectural constraint is statelessness: every request carries everything the server needs, and the server keeps no memory of a client between requests. This chapter verifies why that constraint matters, and then looks at the other half of client-server architecture: how much logic a client should hold at all.

Statelessness, Verified — Not Just Defined

class StatefulCartService: def __init__(self): self.sessions = {} # held in server memory def add_item(self, session_id, item): self.sessions.setdefault(session_id, []).append(item) return list(self.sessions[session_id]) def get_cart(self, session_id): return list(self.sessions.get(session_id, [])) class StatelessCartService: def add_item(self, current_cart, item): new_cart = copy.deepcopy(current_cart) new_cart.append(item) return new_cart # returned to the client, who resends it next time def get_cart(self, current_cart): return list(current_cart)
Verified directly — a stateful design loses data a simulated server restart shouldn't have touched
Adding 'Widget' via StatefulCartService correctly reports ['Widget']. Creating a fresh instance of the same service (simulating a server restart — a new process, a load balancer routing to a different server, anything that discards in-memory state) and calling get_cart('SESSION-1') with the identical session ID returns [] — genuinely empty. The cart didn't survive.
Verified directly — the stateless version survives the identical restart unchanged
The exact same scenario against StatelessCartService: adding 'Widget' returns ['Widget'], which the client holds onto. A fresh service instance, given that same ['Widget'] cart by the client itself, correctly returns ['Widget'] — because the server was never the one remembering it. Nothing about the server's own restart mattered, because nothing the request needed was stored there.
This is why statelessness scales
A stateful server can't be freely load-balanced across multiple machines or restarted for a deploy without losing sessions — the exact bug just verified. A stateless server can be, because every request is self-contained. This is the real reason REST APIs default to statelessness: it's what makes Distributed Systems & Scalability's own load balancing and horizontal scaling chapters actually work without special session-affinity handling.

Thin vs. Thick Clients: Where Should the Logic Live?

A thick client implements business logic itself. A thin client only displays what the server tells it and sends raw requests. What happens when the same logic gets implemented twice, independently, by two thick clients?

def mobile_client_calculate_total(items_total, is_loyalty_member): # written by the mobile team total = items_total if is_loyalty_member: total = total - (total * 0.10) if total > 100: total = total - 5 # applied AFTER the loyalty discount return round(total, 2) def web_client_calculate_total(items_total, is_loyalty_member): # written by the web team total = items_total if total > 100: total = total - 5 # applied BEFORE the loyalty discount if is_loyalty_member: total = total - (total * 0.10) return round(total, 2)
Verified directly — two independently-written thick clients disagree on the identical input
Pricing a $120 order for a loyalty member: mobile_client_calculate_total() returns 103.0; web_client_calculate_total() returns 103.5. Both teams implemented "10% loyalty discount, then $5 off orders over $100" — but disagreed on the order those two rules apply in, producing a genuine $0.50 discrepancy for the same customer, the same order, on two different platforms.

The Thin-Client Fix: One Server, Both Clients Call It

Verified directly — both clients get an identical, correct result once the logic lives in exactly one place
Standing up a real local HTTP server exposing /calculate, backed by a single server_calculate_total() function, and having both a "mobile" and "web" client call it (instead of computing anything themselves): both correctly return 103.5 — identical. Neither client contains the discount logic at all anymore; both just display whatever the one authoritative implementation returns.
The connection to Chapter 5
Two thick clients implementing the same rule independently is coupling, in the same sense Chapter 5 measured it — both codebases depend on agreeing with each other, with nothing enforcing that they actually do. A thin client, calling one API, is the client-server equivalent of Chapter 5's own single-service ownership: exactly one place owns the rule, and every consumer defers to it.

Where a Mobile App and a Web Frontend Both Fit

This is the actual payoff of API-centric architecture: build one stateless, thin-client-facing API, and let as many different client types as needed — a web frontend, a mobile app, a third-party integration — consume the identical endpoints. None of them need their own copy of the business logic, and none of them depend on the server remembering who they are between requests.

Where This Connects

This chapter's findingWhat it connects to
A stateful cart losing data on a simulated restartDistributed Systems & Scalability's own Load Balancing chapter — this is precisely the failure mode session affinity exists to work around, and statelessness avoids needing it at all
A verified $0.50 discrepancy between two independently-implemented thick clientsChapter 5's coupling analysis — duplicated logic across two codebases is a coupling problem, even with no direct function call or shared data between them
One authoritative server endpoint resolving the discrepancyChapter 7's Hexagonal Architecture — the server's own server_calculate_total() is exactly the kind of core logic a real system would put behind ports, testable independently of any client

Hands-On Exercises

Exercise 1

Add a get_item_count(current_cart) method to this chapter's own StatelessCartService. Verify it works correctly even when called against a brand-new service instance (simulating another server restart), given only the cart data the client itself provides.

📄 View solution
Exercise 2

Using this chapter's own mobile_client_calculate_total() and web_client_calculate_total(), find a second input (a different items_total, still a loyalty member) where the two thick clients happen to agree, and explain specifically why the order-of-operations bug doesn't show up for that input.

📄 View solution
Exercise 3

This chapter's own server_calculate_total() matched the web client's own (correct-by-luck) order of operations, not the mobile client's. Explain why "the server happens to agree with one of the two clients" isn't actually the reason a thin-client design fixes the discrepancy — what's the real reason, using this chapter's own verified $120 example?

📄 View solution

Chapter 8 Quick Reference

  • Statelessness, verified: a stateful cart lost its contents ([]) on a simulated server restart; the stateless version, given the same data by the client, survived unchanged
  • Thick clients, verified diverging: two independently-written clients returned 103.0 vs. 103.5 for the identical $120 loyalty-member order — a real $0.50 discrepancy from independently-implemented rule ordering
  • Thin clients, verified converging: both clients returned the identical, correct 103.5 once calling one shared server endpoint instead of implementing the logic themselves
  • Next chapter: Documenting Architecture — ADRs and the C4 Model, so a decision like "thin client, stateless API" gets recorded, not just made