R0KernelPath
module 256 / 262

Software Architecture & APIs

How to organize large software systems so they can grow, change, and survive contact with reality — from layered monoliths to microservices, and from REST APIs to GraphQL.

~18h focused study10 concepts

Overview

Every non-trivial program eventually faces a crisis of complexity. Code that started as a clever weekend project becomes a tangled web of dependencies where changing one thing breaks five others. Software architecture is the discipline that prevents — or at least defers — that crisis. It is the art of imposing structure on complexity, making principled decisions about where things live and how they talk to each other.

Architecture operates at a level above individual algorithms and data structures. It asks: what are the major components of this system, and how do they interact? These decisions have enormous leverage. A poorly chosen architecture locks a team into years of pain; a well-chosen one makes hard extensions feel easy. This is why senior engineers at Google, Facebook, and Amazon spend significant time on architecture before writing a single line of production code.

This module covers two intertwined topics. First, architectural styles: the recurring patterns — like layers, events, and microservices — that humanity has discovered for organizing large systems. Each style is a trade-off, a compromise between competing forces like scalability, simplicity, testability, and operational overhead. Second, APIs (Application Programming Interfaces): the contracts between components. Whether two modules within a monolith, or two services across the internet, they communicate through APIs. The design quality of those APIs determines how easily the system can evolve, how easily bugs are found, and how pleasant it is for developers to use.

We will study REST, RPC, and GraphQL not as tribal loyalties but as tools, each with a domain where it shines and conditions under which it suffers. We will see why documentation is not a bureaucratic afterthought but a first-class engineering artifact. By the end, you will be able to look at a real system — like Netflix, GitHub, or a university's course-registration backend — and reason rigorously about its architecture, its APIs, and their trade-offs.

why this matters for the kernel

Software architecture is one of the highest-leverage skills in all of engineering. A team of ten that chooses the right architecture can outship a team of a hundred stuck in the wrong one. Every major technology transition — the rise of the web, mobile apps, cloud computing, and now AI services — has been accompanied by an architectural reckoning, a rediscovery that yesterday's structure no longer fits today's constraints. The engineers who understood why architectures exist were the ones who could navigate those transitions rather than merely survive them.

In practice, virtually every software job above entry level requires architectural thinking. Interviews at top companies explicitly test system design. More importantly, the code you write every day is shaped by the architectural decisions made before you arrived — understanding them lets you work with the grain of a system rather than against it, spot the sources of tech debt, and make the local choices (which module owns this? what does this function's interface look like?) that accumulate into the overall health of a codebase. API design in particular has direct economic consequences: a well-designed public API is a product that can outlive its original implementation, while a poorly designed one chains you to backward compatibility forever.

REST API Request Lifecycle Through a Layered Architecture

rendering diagram…

A single GET request flows through the API Gateway (routing, rate-limiting), Auth Middleware (JWT validation), Service Layer (business rules), and Repository Layer (data access). The 401 short-circuit shows how layers intercept requests early when invariants are violated, preventing unnecessary work in lower layers.

The concepts

01What Architecture Is (and Is Not)

Architecture is about structure that is hard to change. Not every design decision is architectural; only the ones with broad, lasting consequences. The classic definition from the SEI: "Software architecture is the set of structures needed to reason about the system, which comprise software elements, relations among those elements, and properties of both."

A useful intuition: architecture is the set of decisions you wish you had made on day one. Whether to use a relational database or a document store. Whether to deploy as a monolith or split into services. How module boundaries are drawn. These are architectural decisions because changing them later is expensive — it requires moving data, rewriting interfaces, and retraining teams.

Contrast this with design decisions that are cheap to change: what variable names to use, which sorting algorithm, whether to use a list or a set for a small local collection. These matter, but they are not architecture.

The 4+1 View Model (Kruchten, 1995) is a classic framework for describing architecture from multiple angles:

  • Logical view: the decomposition into objects/modules and their relationships (class diagrams).
  • Process view: runtime behavior — threads, processes, communication.
  • Physical view: hardware, network topology, deployment.
  • Development view: source code organization, build system, team ownership.
  • +1 (Scenarios): use cases that tie the other four views together.

No single view tells the whole story. Good architecture documentation gives stakeholders the views they need.

02Layered (N-Tier) Architecture

The most common and intuitive architecture style: divide a system into horizontal layers, where each layer only talks to the layer directly below it.

The classic 3-tier web application:

  1. Presentation layer (UI / API gateway): receives HTTP requests, renders HTML or marshals JSON.
  2. Business logic layer (application / service): enforces domain rules, orchestrates operations.
  3. Data layer (persistence): reads and writes to databases.

Why is this valuable? Each layer can evolve independently. You can swap PostgreSQL for MySQL without changing business logic. You can rewrite the UI from server-rendered HTML to a React SPA without touching persistence. This is the separation of concerns principle made concrete.

Strict vs. relaxed layering: In strict layering, a layer may only call the layer immediately below. In relaxed layering, it may skip layers. Relaxed is more common in practice because strict layering can add pointless pass-through boilerplate. However, when you find yourself writing a function that does nothing but forward a call unchanged, it is a smell — that intermediate layer may not be earning its keep.

The Law of Leaky Abstractions (Spolsky): all non-trivial abstractions, to some degree, leak. Your ORM will eventually force you to understand SQL. Your HTTP library will eventually force you to understand TCP. Layers reduce complexity but do not eliminate the underlying reality — know what is below you.

Layered architecture is excellent for well-understood CRUD systems. Its weakness: when a feature naturally spans many layers, every change requires touching code in multiple places (the shotgun surgery anti-pattern). This is what motivates the shift to domain-driven, vertical-slice organization in larger systems.

# Python: illustrating clean layer boundaries

# --- data layer ---
class UserRepository:
    def __init__(self, db_connection):
        self._db = db_connection

    def find_by_id(self, user_id: int) -> dict | None:
        return self._db.query_one(
            'SELECT * FROM users WHERE id = %s', (user_id,)
        )

# --- business logic layer ---
class UserService:
    def __init__(self, repo: UserRepository):
        self._repo = repo

    def get_profile(self, user_id: int) -> dict:
        user = self._repo.find_by_id(user_id)
        if user is None:
            raise ValueError(f'User {user_id} not found')
        # business rule: never expose the password hash upward
        return {k: v for k, v in user.items() if k != 'password_hash'}

# --- presentation layer ---
from flask import Flask, jsonify
app = Flask(__name__)

@app.route('/users/<int:user_id>')
def user_profile(user_id):
    svc = UserService(repo)      # injected in real code
    return jsonify(svc.get_profile(user_id))

03Event-Driven Architecture

In a layered or direct-call architecture, component A calls component B synchronously: A waits for B's reply. Event-driven architecture (EDA) inverts this: A publishes an event describing something that happened, and any interested component subscribes to that event and reacts — without A knowing or caring who those components are.

The key abstraction is the message broker (Apache Kafka, RabbitMQ, AWS EventBridge, Redis Streams). Producers emit events; the broker durably stores and routes them; consumers process them asynchronously.

Why events?

  • Decoupling: the order service does not need to know about the email service, the inventory service, or the analytics service. When you add a new downstream effect, you add a new subscriber — you do not change the order service.
  • Temporal decoupling: components can be unavailable and process their event backlog when they come back up.
  • Natural audit log: an ordered log of events is the system's history. Event sourcing (discussed below) takes this to its logical conclusion.

The downside: reasoning about behavior becomes harder. With direct calls, you trace a call stack. With events, you trace a causal chain across multiple services, time, and possibly machines — requiring distributed tracing (Jaeger, Zipkin) to debug. Testing is harder. Eventual consistency must be designed for.

Event sourcing: instead of storing the current state of an entity, store the sequence of events that produced it. The current state is derived by replaying events. This gives you a perfect audit trail and the ability to recompute state from any point in time. The trade-off is query complexity — you typically build read-optimized projections (materialized views) from the event stream. This pattern, combined with CQRS (Command-Query Responsibility Segregation), is used in high-stakes systems like banking ledgers.

Domain events vs. integration events: domain events are things that happened within a bounded context ("OrderPlaced"); integration events cross context boundaries. Keeping these distinct prevents tight coupling at the domain model level.

# Python pseudocode: a simple in-process event bus
import collections
from typing import Callable

class EventBus:
    def __init__(self):
        self._handlers: dict[str, list[Callable]] = \
            collections.defaultdict(list)

    def subscribe(self, event_type: str, handler: Callable):
        self._handlers[event_type].append(handler)

    def publish(self, event_type: str, payload: dict):
        for handler in self._handlers[event_type]:
            handler(payload)          # sync here; real brokers are async

# usage
bus = EventBus()

def on_order_placed(event):
    print(f"Sending confirmation email to {event['email']}")

def on_order_placed_inventory(event):
    print(f"Reserving item {event['item_id']} in warehouse")

bus.subscribe('OrderPlaced', on_order_placed)
bus.subscribe('OrderPlaced', on_order_placed_inventory)

# Order service publishes; knows nothing about downstream handlers
bus.publish('OrderPlaced', {
    'order_id': 42, 'item_id': 7, 'email': 'alice@example.com'
})

04Microservices Architecture

A microservices architecture decomposes a system into a suite of small, independently deployable services, each owning a specific business capability and its own data store. The term was popularized by Martin Fowler and James Lewis around 2014, though the ideas are older.

The key properties:

  • Single responsibility per service: each service does one thing well (user management, payment processing, search indexing).
  • Independent deployability: you can update the payments service without touching the search service.
  • Decentralized data management: each service owns its data. No shared database. Services communicate via APIs or events.
  • Designed for failure: a service must assume its dependencies are unreliable. Circuit breakers, retries with exponential backoff, timeouts, and bulkheads are standard patterns.

The Conway's Law connection: Conway's Law (1968) states that "organizations which design systems are constrained to produce designs which are copies of the communication structures of those organizations." Microservices are often justified as making architecture match team topology — the "inverse Conway maneuver" is to organize teams the way you want the architecture to look. Teams own services end-to-end: build it, run it, watch it (the DevOps philosophy).

When microservices make sense:

  • Large organizations where multiple teams need to deploy independently.
  • Services with very different scaling requirements (the search tier scales differently than the billing tier).
  • Services with different technology requirements (ML inference service in Python, low-latency cache in Go).

When microservices do NOT make sense:

  • Small teams (the operational overhead is enormous: service discovery, distributed tracing, container orchestration, polyglot persistence, network partitions).
  • Early-stage products where the domain is not yet understood (you will carve services along the wrong boundaries and pay dearly).

The now-canonical advice: start with a monolith, identify the seams, then extract services when the pain of staying together exceeds the pain of splitting. The "modular monolith" — a single deployable with carefully enforced module boundaries — is often the right intermediate step.

Key patterns in microservices:

  • API Gateway: single entry point that routes, rate-limits, authenticates, and aggregates.
  • Service mesh (Istio, Linkerd): handles cross-cutting concerns (mTLS, circuit breaking, observability) in the infrastructure layer.
  • Saga pattern: managing distributed transactions without two-phase commit, using a sequence of local transactions coordinated through events or an orchestrator.
# Pseudocode: circuit breaker pattern
# Prevents cascading failure when a downstream service is unhealthy

class CircuitBreaker:
    CLOSED = 'CLOSED'     # requests flow through normally
    OPEN   = 'OPEN'       # requests fail immediately (fast-fail)
    HALF   = 'HALF_OPEN'  # probe: allow one request through

    def __init__(self, failure_threshold=5, recovery_timeout=30):
        self.state = self.CLOSED
        self.failures = 0
        self.threshold = failure_threshold
        self.timeout = recovery_timeout
        self.opened_at = None

    def call(self, fn, *args):
        if self.state == self.OPEN:
            if time.now() - self.opened_at > self.timeout:
                self.state = self.HALF
            else:
                raise ServiceUnavailableError('Circuit open')
        try:
            result = fn(*args)
            self._on_success()
            return result
        except Exception as e:
            self._on_failure()
            raise

    def _on_success(self):
        self.failures = 0
        self.state = self.CLOSED

    def _on_failure(self):
        self.failures += 1
        if self.failures >= self.threshold:
            self.state = self.OPEN
            self.opened_at = time.now()

05API Design Principles

An API is a contract. Once published and depended upon, it is extraordinarily difficult to change. Bad API design is one of the most expensive forms of technical debt, because it propagates into every caller.

Principle 1: Make it easy to use correctly, hard to use incorrectly. The best API makes the right thing obvious and the wrong thing a compile error or a clear runtime error. This is why strongly typed APIs beat stringly typed ones.

Principle 2: Minimal surface area. Expose the minimum interface needed. Every public function, parameter, and field is a promise you must keep. Prefer fewer, more powerful primitives over many narrow ones. You can always add; you can rarely remove.

Principle 3: Consistency. Use the same vocabulary, naming conventions, and patterns throughout. If get_user_by_id returns None on missing, then get_order_by_id should not raise an exception on missing. Consistency reduces cognitive load and surprises.

Principle 4: Semantic clarity over cleverness. Name things after what they are, not what they do. A function named processData tells you nothing. validateAndNormalizePhoneNumber tells you everything.

Principle 5: Backward compatibility and versioning. Additive changes (new optional fields, new endpoints) are safe. Removing or renaming things is a breaking change. Strategies:

  • URL versioning: /api/v1/users, /api/v2/users — clear but leads to maintenance overhead.
  • Header versioning: Accept: application/vnd.myapi.v2+json — cleaner URLs, harder to test in browser.
  • Sunset headers: Deprecation: true, Sunset: Sat, 31 Dec 2026 00:00:00 GMT — signal to clients that a version is going away.

Principle 6: Error information is API surface. Return structured, machine-readable errors with a stable schema — not free-form human strings. RFC 7807 ("Problem Details for HTTP APIs") defines a standard format. A good error tells the caller what went wrong, why, and ideally how to fix it.

Hyrum's Law: with a sufficient number of users of an API, it does not matter what you promise in the interface contract — all observable behaviors of your implementation will be depended upon by somebody. This is a humbling and important insight: once an API is widely used, even bugs become part of the contract.

# Bad API — stringly typed, inconsistent, poor errors
def process(action, data):
    if action == 'create_user':
        # ...
        return {'status': 'ok', 'msg': 'done'}
    elif action == 'delete_user':
        return {'status': 'error', 'msg': 'not found'}

# Good API — typed, intentional, structured errors (Python 3.10+)
from dataclasses import dataclass
from typing import Optional

@dataclass
class User:
    id: int
    name: str
    email: str

@dataclass
class ApiError:
    code: str          # stable machine-readable code
    message: str       # human-readable explanation
    detail: Optional[dict] = None

def create_user(name: str, email: str) -> User | ApiError:
    if not _valid_email(email):
        return ApiError(
            code='INVALID_EMAIL',
            message=f'{email!r} is not a valid email address',
            detail={'field': 'email', 'value': email}
        )
    # ... persist and return
    return User(id=_next_id(), name=name, email=email)

06REST — Representational State Transfer

REST was described by Roy Fielding in his 2000 PhD dissertation as a set of architectural constraints for building networked systems. It is not a protocol or a standard; it is a style. A system that satisfies all the constraints is RESTful.

The six constraints:

  1. Client-server: separation of concerns between UI and data storage.
  2. Stateless: each request contains all the information needed to process it. The server stores no client session state between requests. This makes services horizontally scalable — any server can handle any request.
  3. Cacheable: responses must declare themselves cacheable or not. Caching eliminates many server round-trips.
  4. Uniform interface: this is the central REST constraint, with four sub-constraints:
    • Resource identification in requests: resources are identified by URIs.
    • Manipulation of resources through representations: clients interact with resources through representations (JSON, XML).
    • Self-descriptive messages: each message carries enough information to describe how to process it (Content-Type header, etc.).
    • HATEOAS (Hypermedia As The Engine Of Application State): responses include links to related actions. Rarely followed in practice.
  5. Layered system: client cannot tell whether it is connected directly to the server or to an intermediary (load balancer, cache, CDN).
  6. Code on demand (optional): servers can send executable code to clients (JavaScript).

What most people mean by REST: a resource-oriented API over HTTP that uses standard verbs:

  • GET /users/42 — retrieve user 42 (idempotent, safe)
  • POST /users — create a new user (not idempotent)
  • PUT /users/42 — replace user 42 entirely (idempotent)
  • PATCH /users/42 — partial update (idempotent if designed right)
  • DELETE /users/42 — delete user 42 (idempotent)

Status codes carry semantics: 200 OK, 201 Created, 204 No Content, 400 Bad Request, 401 Unauthorized, 403 Forbidden, 404 Not Found, 409 Conflict, 422 Unprocessable Entity, 429 Too Many Requests, 500 Internal Server Error.

Richardson Maturity Model: Level 0 (one URI, one method), Level 1 (multiple resources), Level 2 (HTTP verbs + status codes), Level 3 (HATEOAS). Most production APIs are at Level 2.

Strengths: HTTP native (caching, load balancing, CDNs all just work), huge ecosystem, easy to debug with curl or a browser, stateless simplifies scaling.

Weaknesses: over-fetching (you get more data than you need) and under-fetching (N+1 requests — you fetch a list, then fetch each item separately). These are the motivating problems for GraphQL.

# A well-designed REST API (Python/Flask)
from flask import Flask, request, jsonify, Response
import http

app = Flask(__name__)

# Resources are nouns, not verbs
# GET  /articles       -> list articles
# POST /articles       -> create article
# GET  /articles/{id}  -> get one article
# PATCH /articles/{id} -> partial update

@app.route('/articles', methods=['GET'])
def list_articles():
    page  = request.args.get('page', 1, type=int)
    limit = min(request.args.get('limit', 20, type=int), 100)
    articles = db.articles.page(page, limit)
    return jsonify({
        'data': articles,
        'pagination': {'page': page, 'limit': limit,
                       'total': db.articles.count()}
    }), http.HTTPStatus.OK

@app.route('/articles/<int:article_id>', methods=['GET'])
def get_article(article_id: int):
    article = db.articles.find(article_id)
    if article is None:
        return jsonify({
            'code': 'NOT_FOUND',
            'message': f'Article {article_id} does not exist'
        }), http.HTTPStatus.NOT_FOUND
    return jsonify(article), http.HTTPStatus.OK

@app.route('/articles', methods=['POST'])
def create_article():
    body = request.get_json(force=True)
    # validate, persist ...
    article = db.articles.create(**body)
    return jsonify(article), http.HTTPStatus.CREATED

07RPC — Remote Procedure Call

Where REST thinks in terms of resources and representations, RPC (Remote Procedure Call) thinks in terms of actions and methods. The client calls a named function on a remote server as if it were a local function call.

The fundamental challenge of RPC is that remote calls and local calls are not the same thing. The Fallacies of Distributed Computing (Deutsch, 1994) list the assumptions programmers incorrectly make when building distributed systems: the network is reliable; latency is zero; bandwidth is infinite; the network is secure; topology does not change; there is one administrator; transport cost is zero; the network is homogeneous. RPC frameworks must deal with all of these, but good ones make the differences visible rather than hiding them.

Protocol Buffers + gRPC (Google): the dominant modern RPC framework.

  • You define a .proto file: a language-neutral schema for messages and services.
  • The protoc compiler generates type-safe client and server stubs in your language.
  • Wire format is binary (much smaller and faster to parse than JSON).
  • Built on HTTP/2: supports multiplexing, bidirectional streaming, and server push natively.
  • Ideal for internal service-to-service communication where both sides are under your control.

tRPC: TypeScript-first RPC framework where the router definition is the type system — no code generation step. The client and server share types directly. Excellent for full-stack TypeScript applications (Next.js), less useful across language boundaries.

JSON-RPC: a simple, language-agnostic RPC protocol using JSON. Requests have method, params, and id fields. Lightweight and easy to implement, used in Ethereum's Web3 API and LSP (Language Server Protocol — the protocol that powers your IDE's autocomplete).

REST vs. RPC: REST shines for public APIs, generic HTTP tooling, caching, and when resource-orientation matches the domain. RPC shines for internal services with complex method vocabularies, streaming, and when performance matters. The dichotomy is often overstated — many APIs are hybrid. The real question is: does your API naturally express things (resources) or actions?

// gRPC: define the contract in a .proto file
// file: user_service.proto
syntax = "proto3";

package user;

service UserService {
  rpc GetUser     (GetUserRequest)    returns (User);
  rpc CreateUser  (CreateUserRequest) returns (User);
  // Streaming RPC: server pushes a stream of events
  rpc WatchUser   (WatchUserRequest)  returns (stream UserEvent);
}

message GetUserRequest {
  int64 user_id = 1;
}

message User {
  int64  user_id = 1;
  string name    = 2;
  string email   = 3;
}

message CreateUserRequest {
  string name  = 1;
  string email = 2;
}

message WatchUserRequest {
  int64 user_id = 1;
}

message UserEvent {
  string event_type = 1;   // e.g. "PROFILE_UPDATED"
  User   user       = 2;
}

// After running `protoc --go_out=. user_service.proto`,
// the compiler generates type-safe stubs.
// The client can then call:
//   resp, err := client.GetUser(ctx, &user.GetUserRequest{UserId: 42})
// as if it were a local function.

08GraphQL

GraphQL (Facebook/Meta, open-sourced 2015) solves a specific, real pain point with REST: over-fetching and under-fetching. In a mobile REST API, a news feed request might need data from users, posts, comments, and likes. With REST you either:

  • Make four separate requests (under-fetching, N+1), or
  • Build a single bespoke endpoint that returns everything (over-fetching, poor reusability, tightly coupled).

GraphQL lets the client specify exactly what data it needs in a single request, and the server returns exactly that — no more, no less.

Core concepts:

  • Schema: a strongly typed contract (SDL — Schema Definition Language) that describes every type, query, mutation, and subscription the API supports.
  • Query: a read operation. The client shapes the response by specifying fields.
  • Mutation: a write operation (create, update, delete). Returns a value so the client can update its cache.
  • Subscription: a long-lived connection for real-time updates.
  • Resolver: a function on the server that knows how to fetch data for a specific field. The runtime calls resolvers recursively to build the response tree.
  • DataLoader pattern: resolvers naively executed can produce N+1 database queries. DataLoader (by Facebook) batches and deduplicates calls within a single request tick.

Strengths:

  • Clients get exactly what they ask for — excellent for bandwidth-constrained mobile clients.
  • Self-documenting: the schema is the documentation, explorable with introspection.
  • Rapid frontend iteration: frontend can add new fields without a backend deploy.
  • Type safety across the full stack (GraphQL Code Generator).

Weaknesses:

  • HTTP caching is hard: all queries go to POST /graphql, making URL-based cache keying impossible. Persisted queries (APQ) partially address this.
  • Complexity for simple cases: if your API is simple CRUD, GraphQL's machinery is overkill.
  • N+1 problem requires explicit mitigation (DataLoader).
  • Security: clients can request arbitrarily expensive queries; requires depth limiting, complexity analysis, and query cost budgets.

When to use GraphQL: public APIs consumed by clients you do not control (especially mobile), systems with complex, graph-shaped data, and teams where frontend and backend evolve independently.

# GraphQL: schema and resolver (Python, using Strawberry)
import strawberry
from typing import Optional

@strawberry.type
class User:
    id: int
    name: str
    email: str

@strawberry.type
class Post:
    id: int
    title: str
    body: str
    author: User

@strawberry.type
class Query:
    @strawberry.field
    def user(self, id: int) -> Optional[User]:
        return db.users.find(id)

    @strawberry.field
    def post(self, id: int) -> Optional[Post]:
        return db.posts.find(id)

schema = strawberry.Schema(query=Query)

# Client sends this GraphQL query:
# {
#   post(id: 1) {
#     title
#     author {
#       name
#     }
#   }
# }
#
# Server returns ONLY:
# { "data": { "post": { "title": "...", "author": {"name": "..."} } } }
# The 'email', 'body' fields are NOT returned — client did not ask.

09API Documentation as a First-Class Artifact

Documentation is engineering, not writing. A brilliant API with poor documentation is a failed API. The best documentation is generated from the code so it cannot go out of date, and interactive so developers can experiment without writing any code.

OpenAPI Specification (formerly Swagger): the dominant standard for describing RESTful APIs. An OpenAPI document (YAML or JSON) describes every endpoint, its parameters, request body schemas, response schemas, status codes, and authentication methods. Tools built on OpenAPI:

  • Swagger UI / Redoc: renders beautiful, interactive API documentation from the spec.
  • Mock servers (Prism): spin up a fake server from the spec before the backend is built, unblocking frontend work.
  • Code generation: generate type-safe client SDKs in any language.
  • Testing: validate that actual responses conform to the spec (contract testing).

Documentation-driven development: write the API spec before implementation. This forces you to think through the interface, lets consumers review it early, and means the spec is always accurate.

The README contract: good library documentation has a fast path ("here is how to do the most common thing in 30 seconds"), a conceptual overview ("here is the mental model"), a reference (every function, parameter, type), and a guide for common tasks. Distinguish tutorial (learning-oriented), how-to (task-oriented), explanation (understanding-oriented), and reference (information-oriented) — the Diátaxis framework.

Changelogs and deprecation: maintain a CHANGELOG.md following Keep a Changelog conventions. Announce deprecations early with clear migration guides. The contract you have with your users is not just the current API — it is the process by which you change it.

# OpenAPI 3.1 spec snippet (YAML)
# This IS the documentation — also drives mock servers and SDK generation
openapi: "3.1.0"
info:
  title: User API
  version: "1.0.0"
paths:
  /users/{userId}:
    get:
      summary: Get a user by ID
      operationId: getUserById
      parameters:
        - name: userId
          in: path
          required: true
          schema:
            type: integer
            minimum: 1
      responses:
        "200":
          description: User found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/User'
        "404":
          description: User not found
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Error'
components:
  schemas:
    User:
      type: object
      required: [id, name, email]
      properties:
        id:    { type: integer }
        name:  { type: string }
        email: { type: string, format: email }
    Error:
      type: object
      required: [code, message]
      properties:
        code:    { type: string }
        message: { type: string }

10Designing for Maintainability

A system is maintainable when it can be understood, changed, and extended by someone who did not build it — including your future self. Maintainability is not a property of code alone; it is a property of code plus its tests, documentation, and the constraints imposed by architecture.

Cohesion and coupling are the two fundamental forces:

  • High cohesion: things that change together live together. A module should do one thing and do it well.
  • Low coupling: modules should depend on each other as little as possible, and through narrow, stable interfaces. The goal: a change in module A should not require changes in module B, C, and D.

Dependency inversion: depend on abstractions, not concretions. Instead of UserService importing PostgresUserRepository directly, it depends on a UserRepository interface. This makes UserService testable (inject a FakeUserRepository) and swappable (you can later add a RedisUserRepository without changing UserService). This is the D in SOLID.

Modular monolith: before splitting into microservices, enforce strict module boundaries within a single process. Each module has a public API (its interface) and private internals. Modules cannot reach into each other's internals. This gives you most of the architectural benefits of microservices without the operational complexity. Only split when a module's deployment or scaling needs genuinely diverge.

Architecture Decision Records (ADRs): short documents (one page) recording a significant architectural decision: the context, the options considered, the decision, and the consequences. Filed alongside the code in version control. Invaluable for new team members who ask "why is it done this way?" ADRs answer that question, preserving the reasoning that was obvious at the time but evaporates over months.

Fitness functions: automated checks that measure architectural properties — coupling metrics, cyclomatic complexity, layer dependency violations. Tools like ArchUnit (Java), dependency-cruiser (Node.js), and import-linter (Python) let you encode architectural rules as tests that fail the CI build if violated. Architecture as code.

# Python: dependency inversion — depend on abstractions
from abc import ABC, abstractmethod

# The abstraction (interface)
class NotificationSender(ABC):
    @abstractmethod
    def send(self, recipient: str, message: str) -> None: ...

# Two concrete implementations
class EmailSender(NotificationSender):
    def send(self, recipient: str, message: str) -> None:
        smtp_client.send(to=recipient, body=message)

class SmsSender(NotificationSender):
    def send(self, recipient: str, message: str) -> None:
        twilio_client.send(to=recipient, text=message)

# The service depends on the abstraction, not the concretion
class OrderService:
    def __init__(self, notifier: NotificationSender):
        self._notifier = notifier

    def place_order(self, order):
        # ... persist order ...
        self._notifier.send(order.customer_email,
                            f'Order {order.id} confirmed!')

# In tests: inject a fake — no network calls
class FakeSender(NotificationSender):
    def __init__(self): self.sent = []
    def send(self, r, m): self.sent.append((r, m))

def test_order_service():
    fake = FakeSender()
    svc  = OrderService(notifier=fake)
    svc.place_order(Order(id=1, customer_email='x@y.com'))
    assert fake.sent == [('x@y.com', 'Order 1 confirmed!')]

Key insights

  • ✦Architecture is the set of decisions that are hard to change — invest proportionally in getting them right early, and document WHY you made them (ADRs).
  • ✦Conway's Law is empirically real: systems mirror their organization charts. The inverse Conway maneuver (organize teams to get the architecture you want) is a legitimate technique.
  • ✦Layered architecture wins for clarity and separation of concerns, but breaks down for cross-cutting features; event-driven wins for decoupling but makes causality hard to trace.
  • ✦REST, RPC, and GraphQL are not competitors — they solve different problems. REST for resource-oriented public APIs; gRPC for high-performance internal services; GraphQL for flexible, graph-shaped data with multiple independent clients.
  • ✦Hyrum's Law is humbling: once enough people use your API, your implementation bugs become part of the contract. Design conservatively and version explicitly.
  • ✦The N+1 query problem is the canonical failure mode of naive ORM/GraphQL usage — understand it deeply and apply the DataLoader pattern.
  • ✦A modular monolith with enforced boundaries is almost always the right starting point. Extract microservices only when a specific module's deployment or scaling needs genuinely differ.
  • ✦Documentation is engineering: generate it from the code, make it interactive (OpenAPI + Swagger UI), and treat spec violations as build failures.

Common pitfalls

  • ⚠Designing microservices before understanding the domain: splitting along the wrong boundaries creates 'distributed monoliths' — services that are coupled in deployment but not in organization, with none of the benefits and all of the operational costs.
  • ⚠Ignoring the N+1 problem in REST and GraphQL: fetching a list of 100 users and then making 100 separate requests for each user's profile is a 101x query amplification — measure your query count in tests.
  • ⚠Treating HTTP status codes as suggestions: returning 200 OK with an error body is a widespread anti-pattern that breaks HTTP caching, monitoring, and every tool that uses standard HTTP semantics.
  • ⚠Breaking API backward compatibility without versioning: changing a field name or type in a live API will silently corrupt client data. Add fields; never remove or rename without a versioned migration period.
  • ⚠Skipping ADRs: the most expensive question in legacy systems is 'why was this done this way?' Teams routinely undo correct decisions because the reasoning was never written down.
  • ⚠Building event-driven systems without distributed tracing from day one: debugging causal chains across Kafka topics without Jaeger or Zipkin is detective work in the dark — instrument before you need it.
  • ⚠Conflating authentication (who are you?) with authorization (what can you do?): these are separate concerns requiring separate solutions; mixing them in a single middleware creates security bugs.

Practice

  1. 01
    medium

    Design the API for a library book-lending system. Identify all resources (book, copy, member, loan). Define REST endpoints for: searching for a book, checking out a copy, returning a copy, listing a member's current loans, and viewing loan history. For each endpoint, specify the HTTP method, URL, request body (if any), success response schema, and at least two error cases with appropriate status codes. Write the OpenAPI spec for at least two of these endpoints.

  2. 02
    easy

    Implement a minimal event bus in Python (or any language) that supports pub/sub with multiple topics. Then model a simplified e-commerce order flow: an OrderService publishes 'OrderPlaced'; an EmailService, InventoryService, and AnalyticsService each subscribe and handle the event independently. Demonstrate that adding a new LoyaltyPointsService requires zero changes to OrderService.

  3. 03
    hard

    You are designing the API for a social network's news feed. Users follow other users; the feed returns posts from people they follow, ordered by recency, with pagination. Analyze this problem from three perspectives: (1) REST with per-resource endpoints, (2) gRPC with a FeedService proto definition, and (3) GraphQL with a schema. For each, write out the API contract and evaluate: how many network round-trips does rendering the feed page require? What data is over-fetched? How hard is it to add a new 'reaction count' field to each post?

  4. 04
    hard

    Write a fitness function (an automated test) for a Python codebase that enforces the following architectural rule: 'no module in the service layer may import from the presentation layer.' Use the ast module or importlib to parse import statements. The test should fail on a provided bad example and pass on a good example.

  5. 05
    medium

    Take any small REST API you have written (or find an open-source one on GitHub). Write its OpenAPI 3.1 spec from scratch, covering at least five endpoints. Then use the Prism mock server (stoplight.io/open-source/prism) to serve mock responses from your spec, and write a test that hits the mock server and validates the response shapes match your spec. This exercise reveals the gap between what you think your API does and what the spec says it does.

Resources

📕Designing Data-Intensive Applications
bookpaidMartin Kleppmann — O'Reilly Media. The single best book on the systems-level thinking that informs architecture decisions at scale. Part III covers derived data and event streams.

Chapters 1, 9, and 11-12 are especially relevant to this module.

📕Software Architecture: The Hard Parts
bookpaidNeal Ford, Mark Richards, Pramod Sadalage, Zhamak Dehghani — O'Reilly Media.

Focused on decomposition trade-offs: when to split, how to split, and the patterns for managing the result.

📜Architectural Styles and the Design of Network-based Software Architectures (Roy Fielding PhD dissertation)
paperfree

The primary source for REST. Chapter 5 defines the constraints. Reading the original dispels many misconceptions about what 'REST' actually means.

✎Martin Fowler — Microservices (article)
articlefree

The seminal article that popularized the term. Read alongside the companion 'Microservice Trade-Offs' and 'MonolithFirst' articles on the same site.

📄GraphQL Specification
docsfree

The formal specification. Sections 2-6 cover the language and execution model.

📄gRPC Documentation
docsfree

Official docs including the core concepts guide and language-specific tutorials.

📄OpenAPI Specification 3.1
docsfree

The specification itself. Swagger's quickstart guide is a gentler on-ramp.

✎Architecture Decision Records (ADRs) — Michael Nygard
articlefree

The original blog post proposing ADRs. Short, essential reading.

📕Fundamentals of Software Architecture
bookpaidNeal Ford and Mark Richards — O'Reilly Media.

Broad survey of architectural styles and characteristics. Good complement to Kleppmann for the software-architecture-specific vocabulary.

🎓MIT 6.033 Computer System Engineering
coursefree

MIT's core systems course. Lectures on modularity, naming, and fault tolerance are directly relevant to this module.

✎Diátaxis — a systematic framework for technical documentation
articlefree

The framework behind distinguishing tutorials, how-to guides, explanations, and reference. Essential for anyone writing API documentation.

Checkpoints — you can now…

  • ✓You can explain the difference between architectural styles (layered, event-driven, microservices) and identify which forces make each appropriate — and can describe at least one concrete failure mode of each.
  • ✓You can design a clean REST API for a new domain: choosing correct HTTP verbs and status codes, structuring URLs around resources, and writing structured error responses.
  • ✓You can articulate the difference between REST, gRPC, and GraphQL — not just superficially but in terms of the specific trade-offs around caching, type safety, streaming, over-fetching, and tooling.
  • ✓You can write an OpenAPI spec for a non-trivial REST API with multiple resources, relationships, and error cases, and use it to generate or validate client/server code.
  • ✓You can explain Hyrum's Law and Conway's Law, and give a concrete example of each from real systems.
  • ✓You can describe the N+1 query problem, explain why it appears in both REST and GraphQL contexts, and explain how DataLoader and compound documents address it.
  • ✓You can write an Architecture Decision Record for a real decision you have made or observed, including context, alternatives considered, and consequences.