What is Client-Server Architecture β and Why Does It Exist?
The client-server model is a way of distributing a computing task between two cooperating roles: a client that initiates a request, and a server that listens for requests, processes them, and returns a response. Every layer of the modern web β a browser talking to a REST API, a microservice calling another microservice, a mobile app syncing data β is an instance of this same pattern.
It exists to solve a concrete problem: where should application logic, data, and control over both live? Before this model was standard, that question was answered in two bad ways β either everything lived on one expensive central machine (the mainframe era), or business logic and direct database credentials were baked into every desktop client that shipped (the two-tier era). Both put the wrong things in the wrong place. Client-server architecture β and specifically its three-tier evolution β separates presentation, business logic, and data storage into independently deployable, independently scalable layers.
/*
* BEFORE β two-tier: the desktop client talks directly to the database.
* Business rules and database credentials live inside every installed copy
* of the client. This was completely standard in the 1990s and is still
* the default mistake newcomers make when they first connect a UI to a DB.
*/
public class OrderDesktopClient {
public void placeOrder(Long productId, int quantity) throws SQLException {
// The client owns a raw JDBC connection with real DB credentials.
// Anyone who decompiles this .jar has your database password.
Connection conn = DriverManager.getConnection(
"jdbc:postgresql://prod-db:5432/shop", "app_user", "h4rdc0d3d");
// Business rule ("check stock before selling") lives in the CLIENT.
// Every desktop installation must be updated to change this rule,
// and a malicious or buggy client can simply skip the check.
ResultSet rs = conn.prepareStatement(
"SELECT stock FROM products WHERE id = " + productId).executeQuery();
if (rs.next() && rs.getInt("stock") >= quantity) {
conn.prepareStatement(
"UPDATE products SET stock = stock - " + quantity +
" WHERE id = " + productId).executeUpdate(); // SQL injection too
}
}
}
/*
* AFTER β three-tier: the client only knows an HTTP contract.
* Business logic and data access move behind a server the client never sees.
*/
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderService orderService; // constructor injection
public OrderController(OrderService orderService) {
this.orderService = orderService;
}
@PostMapping
public OrderResponse placeOrder(@Valid @RequestBody OrderRequest request) {
// Credentials never leave the server. The stock rule can change
// without touching a single client. The client only ever sees JSON.
return orderService.placeOrder(request);
}
}
// The client's entire responsibility is now this:
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create("https://shop.example.com/api/orders"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
Client and Server Are Roles, Not Machines
The single most common misunderstanding when developers first meet this model: assuming "client" and "server" describe fixed types of hardware or software. They don't. They describe who initiated the request in a given exchange. The exact same process can be a server in one connection and a client in another, simultaneously.
// A single Spring Boot application, two roles at once:
Acts as SERVER Acts as CLIENT
(for the browser) (for the database)
β β
ββββββββββββ ββββββ΄ββββββββββββββββββββββββββββββ΄βββββ ββββββββββββ
β Browser β Request β β Query β Postgres β
β (Client) β βββββββΆ β OrderController β βββββββΆ β (Server) β
β β βββββββ β β β βββββββ β β
ββββββββββββ Responseβ OrderService β Result ββββββββββββ
β β β
β PaymentClient βββββββββββΆ β Payment Service
βββββββββββββββββββββββββββββββββββββββββ (server, elsewhere)
// In a microservice chain the same rule holds at every hop:
Order Service ββ(client)βββΆ Payment Service ββ(client)βββΆ Fraud-Check Service
This matters in real code, not just in diagrams. A service class that calls out to another microservice is acting as a client the moment it does so β and should be built with the same discipline you'd expect from any HTTP client: timeouts, retries, and circuit breakers, because the "server" on the other end can fail or be slow just like any external dependency.
@Service
public class OrderService {
private final OrderRepository orderRepository;
private final PaymentClient paymentClient; // this class is a CLIENT
public OrderService(OrderRepository orderRepository, PaymentClient paymentClient) {
this.orderRepository = orderRepository;
this.paymentClient = paymentClient;
}
public OrderResponse placeOrder(OrderRequest request) {
Order order = orderRepository.save(new Order(request.productId(), request.quantity()));
// OrderService is a client of PaymentService here β same failure modes
// as any external HTTP call: it can time out, it can 5xx, it can be slow.
PaymentResult result = paymentClient.charge(order.id(), request.amount());
return new OrderResponse(order.id(), result.status());
}
}
The Three Components: Client, Server, and Network
The Client
A client's job is to gather intent from a user (or another system), turn it into a well-formed request, send it, and do something useful with whatever comes back. It does not need to know how the server fulfills the request β only the contract for asking.
| Client type | Typical Java-ecosystem tooling | Notes |
|---|---|---|
| Web browser | fetch(), XMLHttpRequest |
Renders HTML/CSS, executes JS, is itself a full HTTP client |
| Mobile app | Retrofit / OkHttp (Android), URLSession (iOS) | Native apps still just send HTTP requests underneath |
| Another backend service | RestClient / WebClient (Spring 6+), HttpClient (Java 11+), Feign, gRPC stubs |
RestTemplate is in maintenance mode; prefer RestClient for new synchronous code and WebClient for reactive code |
| CLI / automation | curl, httpie, CI pipeline scripts |
Useful for testing a server in isolation from any UI |
The Server
A server binds to a port, listens continuously, and for each incoming connection: authenticates the caller, authorizes the action, executes business logic, touches whatever data store it needs, and serializes a response. Spring Boot and Jakarta EE containers handle the socket plumbing and threading for you β your code only has to fill in the business-logic step.
// Server side β modern Spring Boot 3.x, constructor injection, record DTOs
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService; // single constructor β @Autowired not needed
}
@GetMapping("/{id}")
public ProductResponse getProduct(@PathVariable Long id) {
return productService.findById(id); // never return the JPA entity directly
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public ProductResponse createProduct(@Valid @RequestBody ProductRequest request) {
return productService.create(request);
}
}
// DTOs are records β immutable, no proxy needed, no accidental lazy-loading
// of JPA associations leaking into a JSON response.
public record ProductRequest(@NotBlank String name, @Positive BigDecimal price) {}
public record ProductResponse(Long id, String name, BigDecimal price) {}
Returning an entity directly couples your public API contract to
your persistence model β a column rename becomes a breaking API
change β and risks serializing lazily-loaded associations that
trigger extra queries or throw
LazyInitializationException outside the persistence
context. A record DTO is a deliberate, stable boundary between
"what the database looks like" and "what the client is allowed to
see." See JPA for why entities
must never be records themselves (proxying requirements).
The Network: Layers and Ports, Just Enough to Debug Confidently
Day to day you work almost entirely at the application layer (HTTP). But when a connection refuses, times out, or gets blocked by a firewall, knowing which layer you're debugging saves hours.
| Layer | Handles | You touch this when... |
|---|---|---|
| Application (HTTP, WebSocket, gRPC) | Requests, headers, JSON bodies | Writing controllers, clients β this is where you live daily |
| Transport (TCP, UDP) | Reliable ordered delivery, ports | Diagnosing "connection refused" vs "connection reset" |
| Network (IP) | Addressing and routing | Debugging DNS, VPCs, or "unreachable host" errors |
| Physical/Link (Ethernet, Wi-Fi) | Raw bit transmission | Almost never, as an application developer |
A port identifies which application on a machine should receive the traffic β the IP address gets you to the host, the port gets you to the right process.
// Ports worth memorizing:
Port 80 β HTTP (unencrypted)
Port 443 β HTTPS (encrypted β the production default)
Port 8080 β Common Spring Boot development default
Port 5432 β PostgreSQL
Port 3306 β MySQL
Port 6379 β Redis
// One host, several independently deployed services on different ports:
192.168.1.100
βββ :8080 β user-service
βββ :8081 β order-service
βββ :8082 β payment-service
DNS resolution, the TCP handshake, and the exact anatomy of an HTTP request line are covered in depth in HTTP Protocol & Methods and Request/Response Cycle β this page focuses on the architectural shape, not the wire protocol.
Architecture Patterns: Two-Tier, Three-Tier, and Microservices
Two-Tier β Client Talks Directly to the Database
ββββββββββββ ββββββββββββ
β CLIENT β βββββββββββΆ β DATABASE β
β (Desktop)β βββββββββββ β (MySQL) β
ββββββββββββ ββββββββββββ
// Still seen in legacy internal tools and quick scripts.
// Acceptable for a single trusted internal user; wrong for anything
// exposed to the public internet β see Section 0's before/after example.
Three-Tier β The Default for Good Reason
ββββββββββββ ββββββββββββ ββββββββββββ
β CLIENT β βββββΆ β SERVER β βββββΆ β DATABASE β
β (Browser)β β (Spring β β (Postgre β
β β βββββ β Boot) β βββββ β SQL) β
ββββββββββββ ββββββββββββ ββββββββββββ
β
βββββββββββ΄ββββββββββ
Presentation Business logic
(Controllers) (Services)
// One deployable server holds business logic; database credentials never
// leave it; the client is replaceable (web, mobile, CLI) without touching
// the rules that govern the business.
N-Tier / Microservices β Splitting the Middle Tier Itself
ββββββββββββ βββββββββββββββββββββββββββββββββββββββ
β CLIENT β βββββΆ β API Gateway β
ββββββββββββ βββββββββββββββββββββββββββββββββββββββ
β
ββββββββββββββββΌβββββββββββββββ
βΌ βΌ βΌ
ββββββββββββ ββββββββββββ ββββββββββββ
β User β β Order β β Payment β
β Service β β Service β β Service β
ββββββ¬ββββββ ββββββ¬ββββββ ββββββ¬ββββββ
βΌ βΌ βΌ
ββββββββββββ ββββββββββββ ββββββββββββ
β User DB β β Order DB β βPayment DBβ
ββββββββββββ ββββββββββββ ββββββββββββ
// Each service is independently deployable and independently scalable β
// the trade-off is that "the server" is no longer one thing you can reason
// about locally: distributed failure modes replace in-process ones.
Microservices solve independent scaling and independent deployment at the cost of network calls where you used to have method calls, distributed transactions where you used to have one ACID transaction, and operational overhead (service discovery, observability, versioned contracts between teams). A well-built three-tier monolith with clean internal module boundaries will outperform a poorly-decomposed set of microservices on almost every axis except independent team deployment. Reach for this split when the reason is organizational (multiple teams needing independent release cadences) or a genuinely different scaling profile between components β not by default.
Stateless vs Stateful Servers
This decision shapes how easily your server scales horizontally, and it is made per-endpoint, not just per-application.
| Aspect | Stateless | Stateful |
|---|---|---|
| Where client identity lives | Inside every request (e.g. a JWT) | In server-side memory, keyed by a session ID |
| Horizontal scaling | Trivial β any instance can serve any request | Needs sticky sessions or a shared session store |
| Typical use | REST APIs | WebSocket connections, classic server-rendered sessions |
| Failure recovery | Any healthy instance recovers seamlessly | Losing the instance holding the session loses the session |
// STATELESS β every request is self-describing; any instance can serve it
@GetMapping("/api/profile")
public UserResponse getProfile(@RequestHeader("Authorization") String bearerToken) {
Long userId = jwtService.extractUserId(bearerToken);
return userService.findById(userId);
}
// STATEFUL β the server must remember which instance holds this session
@GetMapping("/api/profile")
public UserResponse getProfile(HttpSession session) {
Long userId = (Long) session.getAttribute("userId");
return userService.findById(userId);
}
Session and cookie mechanics β how a session ID actually gets to the server on every request, and what a load balancer does with sticky sessions β are covered fully in Sessions & Cookies. JWT structure and validation are covered in JWT (JSON Web Tokens).
Best Practices and Common Pitfalls
β Do
- Keep business logic and database credentials on the server β never in a client, no matter how "trusted" that client feels
- Return DTOs (records), never JPA entities, from controller methods
- Prefer stateless endpoints (token-based identity) whenever the operation allows it β it's what makes horizontal scaling trivial
- Treat calls to other services as client calls with the same discipline as any external HTTP dependency: timeouts, retries with backoff, and circuit breakers
- Choose three-tier as the default architecture; justify microservices with a concrete organizational or scaling reason, not novelty
β Don't
- Don't let a desktop or mobile client hold direct database credentials or connection strings
- Don't assume "my server" only ever plays the server role β the moment it calls another service or the database, it is a client and can fail like one
- Don't default to stateful sessions for new REST APIs unless there's a specific reason (e.g. a WebSocket connection genuinely needs pinned state)
- Don't split a monolith into microservices to chase a trend β the distributed-systems failure modes you inherit are real and permanent
Interview Questions
Q: What is the difference between a client and a server?
A client initiates a request; a server listens for requests, processes
them, and returns a response. These are roles defined by who initiates
the exchange, not fixed properties of a machine or program.
Q: Why is three-tier architecture preferred over having the client talk directly to the database?
Three-tier keeps database credentials and business logic on the server,
where they can be secured and changed centrally without touching every
installed client. Direct client-to-database access exposes credentials,
duplicates business rules across every client installation, and makes
those rules trivially bypassable.
Q: What does it mean for a server to be "stateless"?
A stateless server keeps no memory of previous requests from a given
client between calls β every request carries all the information
needed to process it (typically a token identifying the user). This
means any server instance can handle any request, which is what makes
horizontal scaling straightforward.
Q: Can the same application be both a client and a server? Give a concrete example and explain the implication for how you'd build it.
Yes β a typical Spring Boot service is a server when it receives HTTP
requests from a browser or another service, and a client the moment it
queries its own database, calls an external API, or calls another
microservice. The implication is that the outbound calls it makes as a
client deserve the same resilience engineering you'd apply to any
external dependency: explicit timeouts, retry policies with backoff,
and circuit breakers β because the process being called can fail or
degrade independently of the caller, and a synchronous chain of
services with no timeouts turns one slow dependency into a
cascading outage.
Q: What specifically breaks when you move from a three-tier monolith to microservices, and how do teams typically compensate?
Three things that were free inside a single process stop being free:
(1) method calls become network calls, introducing latency and partial
failure where there was none before; (2) a single ACID database
transaction across what used to be one schema becomes a distributed
consistency problem, typically compensated for with the Saga pattern
and eventual consistency rather than XA transactions; (3) a single
deployable artifact with one log stream becomes N independently
deployed services requiring distributed tracing, centralized logging,
and service-to-service contract versioning to remain debuggable. Teams
compensate with API gateways, service meshes, and observability
tooling β none of which existed as a concern in the monolith.
Q: When would you deliberately choose a stateful server design for a new feature in 2026, given that stateless is the default recommendation?
WebSocket-based features are the clearest case: a persistent bidirectional
connection is inherently pinned to the server instance that accepted it,
so there is no meaningful "stateless" version of that connection β the
server must hold connection state for as long as the socket is open.
Real-time collaborative editing, live notifications, and trading
dashboards are typical examples. Even there, teams often keep the
durable state (e.g. document content) in a shared store like Redis so
that only the live connection, not the underlying data, is pinned to one
instance β limiting the blast radius if that instance goes down.