Client-Server Architecture

Roles, not machines: how clients and servers divide responsibility, why three-tier architecture won, and where your Spring Boot app is both client and server at once

← Back to Index

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 typeTypical Java-ecosystem toolingNotes
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) {}
Why not just return the JPA entity?

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.

LayerHandlesYou touch this when...
Application (HTTP, WebSocket, gRPC)Requests, headers, JSON bodiesWriting controllers, clients β€” this is where you live daily
Transport (TCP, UDP)Reliable ordered delivery, portsDiagnosing "connection refused" vs "connection reset"
Network (IP)Addressing and routingDebugging DNS, VPCs, or "unreachable host" errors
Physical/Link (Ethernet, Wi-Fi)Raw bit transmissionAlmost 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
The mechanics of a single request/response are covered elsewhere

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.
Splitting into microservices is an organizational decision before it's a technical one

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.

AspectStatelessStateful
Where client identity livesInside every request (e.g. a JWT)In server-side memory, keyed by a session ID
Horizontal scalingTrivial β€” any instance can serve any requestNeeds sticky sessions or a shared session store
Typical useREST APIsWebSocket connections, classic server-rendered sessions
Failure recoveryAny healthy instance recovers seamlesslyLosing 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

πŸŽ“ Junior level

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.

πŸ”₯ Senior level

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.