Distributed Systems Fallacies

A Pocket Reference

Alan Bradley

uradical.io

Distributed Systems Fallacies

Alan Bradley

© 2026 uRadical

This work is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.

You are free to share and adapt this work for non-commercial purposes, as long as you give appropriate credit and distribute your contributions under the same license.

https://creativecommons.org/licenses/by-nc-sa/4.0/

Table of Contents

Chapter 0: Origins

Chapter 1: The Network Is Reliable

Chapter 2: Latency Is Zero

Chapter 3: Bandwidth Is Infinite

Chapter 4: The Network Is Secure

Chapter 5: Topology Doesn't Change

Chapter 6: There Is One Administrator

Chapter 7: Transport Cost Is Zero

Chapter 8: The Network Is Homogeneous

Chapter 9: The Meta-Fallacy

Chapter 0: Origins

Your system is running on a lie.

Not a malicious one. A well-intentioned, beautifully engineered lie — one that your cloud provider has spent billions of dollars making convincing. The lie is that the network beneath your application is reliable, fast, secure, and free. It is none of these things. It has never been any of these things. And the longer the lie holds, the worse it will be when it breaks.

I've been building distributed systems from the dotcom era to the present day — SaaS, travel, finance, trading, telecoms. In that time I've watched a Royal Navy submarine rip a cable out of the Irish Sea. I've seen a digger tear up a line during a live auction. I've lost count of the configuration errors, the silent failures, the incidents that looked impossible until they happened. Disaster stalks distributed systems in ways you cannot anticipate, and the one constant through all of it — across every industry, every decade, every technology shift — is that Peter Deutsch was right.

In 1994, Deutsch got tired of watching smart people make the same mistakes and wrote them down.

The List

Deutsch was a Distinguished Engineer at Sun Microsystems. Sun was building the infrastructure of the early commercial internet — the servers, the operating systems, the languages. Their engineers were among the first to build distributed systems at real scale, and the pattern was always the same — new engineers, same assumptions, same failures. Deutsch distilled those assumptions into seven beliefs that every engineer new to distributed systems held, and that every distributed system eventually punished them for holding. Around 1997, James Gosling — Sun Fellow, creator of Java — added an eighth.

Together, they became the Eight Fallacies of Distributed Computing:

The network is reliable

Latency is zero

Bandwidth is infinite

The network is secure

Topology doesn't change

There is one administrator

Transport cost is zero

The network is homogeneous

These are not design preferences or architectural opinions. They are statements about physical and organisational reality — the kind of reality that doesn't care what your architecture diagram looks like.

The Abstraction Trap

Thirty years later, you might expect these to be fully absorbed into how we build software. They are not. The reason is counterintuitive.

The fallacies haven't been forgotten because the industry moved past them. They've been forgotten because the industry got very good at hiding them.

Modern cloud platforms are an elaborate magic trick. They present an interface where the network appears reliable, latency appears negligible, bandwidth appears unlimited, and the whole thing appears to be managed by a single benevolent administrator. This works almost all of the time.

The almost is where systems die.

When you deploy to a managed platform and everything communicates over internal service meshes, managed queues, and auto-scaling load balancers, every single fallacy is still in play. The difference is that you never had to think about any of them during development, during testing, or during the first eleven months of production. You think about them at 3am on month twelve, when something you never designed for happens and your system has no resilience against it — because you never knew it needed any.

The previous generation learned these lessons through direct exposure. They opened TCP sockets and connections dropped. They shipped data across WANs and latency mattered. They managed their own hardware and watched networks change shape under their feet.

The current generation learns through abstraction. The lessons are still there, buried under layers of platform that make the fallacies feel false right up until the moment they become catastrophically true.

What This Book Does

Most treatments of the fallacies stop at description — the what, not the what to do about it. This book gives you the countermeasures. Each chapter takes one fallacy, explains why it's true, shows how it manifests in modern systems, and provides concrete patterns, techniques, and design decisions that account for the reality Deutsch described.

We use a deliberately simple running example throughout: a multi-tenant system backed by SQLite, one database per tenant, replicated across nodes. Not because SQLite is the only right answer — we use plenty of other databases where they're the better fit. It's because SQLite is small enough that you can hold the entire model in your head. The complexity in each chapter comes from the distributed problem, not from understanding the datastore. Where a different technology makes a point more clearly, we'll use it. Pragmatism over purity.

But the bias throughout is towards simplicity, minimal dependencies, and solutions you can reason about. Because when the network is lying to you at 3am, the only thing that saves you is a system simple enough to understand under pressure.

Chapter 1: The Network Is Reliable

It isn't.

This is the foundational fallacy — the one that underpins all the others. If the network were truly reliable, latency wouldn't matter (you'd always get a response), security would be simpler (no interrupted handshakes, no replayed packets), and topology would be irrelevant (every path would work, always). The network's unreliability is the root from which every other fallacy grows.

And yet it's the assumption engineers make most naturally. You call a function, you get a result. You make an HTTP request — same thing, right? The entire programming model of a network call is designed to feel like a local function call. That's a deliberate abstraction, and it's a useful one, right up until the network does what networks do.

What Actually Happens

Networks fail in three ways, and only one of them is obvious.

Total failure is the easy one. The connection is refused, the host is unreachable, the cable is cut. Your code gets an error. You know something is wrong. This is the failure mode that most retry logic is designed for, and it's the least dangerous because it's the least ambiguous.

Partial failure is harder. The request leaves your machine. It may or may not reach the server. The server may or may not process it. The response may or may not come back. You don't know which of these happened. Your code gets a timeout, which tells you nothing about the state of the world. Did the payment go through? Did the record get written? You don't know, and the network can't tell you.

Byzantine failure is the worst. The network appears to be working. Packets are flowing. But something between you and the destination is corrupting data, reordering messages, or silently dropping a percentage of traffic. Your system doesn't crash — it produces wrong results. These failures can persist for hours or days before anyone notices, because every health check passes and every dashboard is green.

Consider our running example: a multi-tenant booking system with SQLite databases replicated across nodes. A client submits a booking. The write hits the primary node. The primary writes to the tenant's database and begins replicating to the secondary. The network between primary and secondary drops for 300 milliseconds.

The primary has the booking. The secondary doesn't — or has a partial write. The client got a success response, or maybe they didn't. If the network blip affected the return path, the client might retry, creating a duplicate booking. Every component did exactly what it was designed to do. The network simply wasn't reliable, and the system had no strategy for that.

So what does a system with a strategy look like?

Make Retries Safe: Idempotency

The client in our booking scenario doesn't know if the first request succeeded. They're going to retry. Your system must handle that retry without creating a second booking. This isn't a performance optimisation — it's a correctness requirement.

The standard pattern is an idempotency key: the client generates a unique identifier for each logical operation and sends it with every attempt. The server checks whether it's already processed that key before doing the work.

func (s *BookingService) CreateBooking(ctx context.Context, req BookingRequest) (*Booking, error) {
    // Check if we've already processed this request
    existing, err := s.store.GetByIdempotencyKey(ctx, req.TenantID, req.IdempotencyKey)
    if err != nil {
        return nil, fmt.Errorf("idempotency check: %w", err)
    }
    if existing != nil {
        return existing, nil
    }

    booking := &Booking{
        ID:             ulid.Make().String(),
        TenantID:       req.TenantID,
        IdempotencyKey: req.IdempotencyKey,
        // ... remaining fields
    }

    if err := s.store.Insert(ctx, booking); err != nil {
        return nil, fmt.Errorf("insert booking: %w", err)
    }

    return booking, nil
}

The idempotency key must be generated by the caller, not the server. If the server generates it, a retry creates a new ID and you're back to duplicates. The key must be stored atomically with the operation it guards — if they're in different transactions, you have a gap where a failure can still produce duplicates.

Make Retries Polite: Backoff and Jitter

A client that retries immediately — and keeps retrying — turns a transient failure into a denial-of-service attack on your own infrastructure. When a service is struggling under load, the last thing it needs is every client simultaneously hammering it with retries.

Exponential backoff with jitter solves this. Each retry waits longer than the last, and a random component prevents all clients from retrying in lockstep.

func retry(ctx context.Context, maxAttempts int, fn func() error) error {
    var err error
    for attempt := range maxAttempts {
        if err = fn(); err == nil {
            return nil
        }

        if attempt == maxAttempts-1 {
            break
        }

        // Exponential backoff: 100ms, 200ms, 400ms, 800ms...
        base := time.Duration(1<<uint(attempt)) * 100 * time.Millisecond

        // Add jitter: 0 to 100% of base
        jitter := time.Duration(rand.Int64N(int64(base)))
        wait := base + jitter

        select {
        case <-ctx.Done():
            return ctx.Err()
        case <-time.After(wait):
        }
    }
    return fmt.Errorf("failed after %d attempts: %w", maxAttempts, err)
}

Three decisions matter. First, what to retry: transient failures (timeouts, 503s, connection resets), not permanent ones (400s, 404s, authentication failures). Retrying a bad request five times doesn't make it good. Second, when to stop: set a maximum attempt count and a maximum total duration. Third, what to do when retries are exhausted — which brings us to the most important design decision in this chapter.

The Decision: Fail Fast or Queue

When retries are exhausted and the downstream is genuinely unavailable, you have two options. Getting this decision right matters more than any individual pattern.

Fail fast means returning an error to the caller immediately. This is the right choice when the operation is time-sensitive and the caller can handle the failure. A user trying to load a page should get a fast error and a retry button, not a spinner that runs for two minutes.

Queue for later means accepting the work now and processing it when the dependency recovers. This is the right choice when the operation must eventually happen but doesn't need to happen synchronously. Sending a confirmation email, updating a search index, triggering a webhook — these can wait.

Most operations are a mix of both. The booking itself is synchronous — the user needs to know it worked. The confirmation email is asynchronous — it can wait. Separating these correctly is the architectural decision that determines whether a network failure is a user-facing outage or a delayed notification.

The mechanism can be as simple as writing the pending operation to a local table:

func (s *BookingService) ConfirmBooking(ctx context.Context, booking *Booking) error {
    // The booking itself is synchronous — the user needs to know it worked
    if err := s.store.Confirm(ctx, booking); err != nil {
        return err
    }

    // Notification is async — queue it, don't block on it
    task := OutboxTask{
        ID:        ulid.Make().String(),
        Type:      "send_confirmation",
        Payload:   booking.ID,
        CreatedAt: time.Now(),
    }
    return s.store.InsertTask(ctx, task)
}

A background worker picks up pending tasks and processes them. If the email service is down, the task stays in the queue and gets retried on the next sweep. The user got their booking confirmation. The email arrives when the network allows it.

This is the transactional outbox pattern, and it sidesteps the reliability problem entirely for asynchronous work. The network between you and the email provider doesn't need to be reliable at the moment of the booking — it just needs to be reliable eventually.

Protect the System: Circuit Breakers and Timeouts

Two more patterns complete the picture: circuit breakers for sustained failures, and timeouts for everything.

A circuit breaker tracks failure rates against a downstream dependency. When failures exceed a threshold, the breaker opens and requests fail immediately without attempting the call. After a cooldown, a single probe request tests whether the downstream has recovered. If it has, traffic resumes. If not, the breaker stays open. The implementation is a simple state machine — closed, open, half-open — wrapped around your call sites. The value isn't in the code, it's in the decision: stop wasting resources on a dependency you already know is down, and give it room to recover instead of piling on.

Timeouts need to be on every network call, without exception. A call without a timeout is a call that can hang forever — a goroutine that never returns, a connection that never closes, a resource that never gets freed. The subtle part is that timeouts must be hierarchical. If your API handler has a 10-second deadline, and it makes three sequential downstream calls, each of those calls can't also have a 10-second timeout — the total would be 30 seconds, well past the handler's deadline. Go makes this straightforward with context:

func (h *Handler) HandleRequest(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
    defer cancel()

    // Each downstream call inherits the remaining deadline
    booking, err := h.bookings.Create(ctx, req)
    if err != nil {
        // If we're close to the deadline, don't bother with notifications
        if dl, ok := ctx.Deadline(); ok && time.Until(dl) < 2*time.Second {
            respondPartial(w, booking)
            return
        }
    }
    _ = h.notifications.Send(ctx, booking)
}

The remaining budget is visible at every level. If a slow database query eats seven seconds, the notification call gets three, not ten.

Detect the Invisible: Byzantine Failures

Total and partial failures announce themselves. Byzantine failures don't. Your system keeps running, producing subtly wrong results, while every health check passes and every dashboard stays green.

The countermeasure is end-to-end verification. Don't trust that data arrived correctly just because the transport layer said it did. Checksum payloads at the application level. Verify row counts after replication. Run consistency checks between your primary and secondary databases on a schedule. Log enough to make silent corruption visible.

In our booking system, this might be a periodic job that compares the primary and secondary tenant databases, flagging any divergence. It's not glamorous work. It's the work that catches the failures that nothing else will.

The Principle

The network is not reliable. Your system must be.

Every network call is a request that might not arrive, a response that might not return, and a state change that might or might not have happened. Design for all three. Make operations idempotent so retries are safe. Use backoff and jitter so retries don't amplify failure. Decide, for every operation, whether failure means fail fast or try later. Use circuit breakers so sustained failures are detected, not endured. Set timeouts so no single call can hold your system hostage. And verify, because the network won't tell you when it's lying.

None of this requires a framework. None of it requires a platform. It requires thinking clearly about what happens when the network does what Deutsch told you it would do.

Chapter 2: Latency Is Zero

It isn't. It can't be. The speed of light is finite, and your data is not exempt from physics.

Light in a vacuum covers roughly 300 kilometres per millisecond. Light in a fibre optic cable — which is what your data actually travels through — does about 200. A round trip from London to New York is approximately 11,000 kilometres of cable. At best, that's 55 milliseconds of pure physics before a single byte of application logic executes. Add routing, switching, TLS handshakes, serialisation, deserialisation, and the actual work your server does, and you're looking at 80–150 milliseconds for a simple request.

That sounds small. It isn't.

Why It Feels Like Zero

On your development machine, a function call takes nanoseconds. A call to a local database takes microseconds — maybe a low single-digit millisecond. A call to a service running in the same Docker Compose stack takes a few milliseconds at most. In this environment, latency genuinely is close to zero, and your intuitions about system behaviour are built in this environment.

Then you deploy. Your database is across a network hop. Your services are in different availability zones — maybe different regions. Your third-party payment provider is on another continent. Each call that felt instant in development now carries real latency, and that latency compounds.

This is the trap. You don't design for latency on your laptop because you can't feel it. By the time you can feel it — in staging, in production, under load — the architecture is set.

What Actually Happens

Latency doesn't just slow things down. It changes what's possible.

Latency compounds across call chains. If your request handler calls Service A, which calls Service B, which queries a database, the total latency is at least the sum of all three calls. A 20-millisecond call isn't a problem. Five of them in sequence is 100 milliseconds. Add a sixth that hits a slow query or a cold cache, and you're at 250 milliseconds — perceptible to users. Add fan-out where one of those services makes its own downstream calls, and you're in the territory where pages feel sluggish and users start dropping off.

Tail latency is the real enemy. Your median response time might be 40 milliseconds. That's the number on the dashboard, and it looks fine. But your p99 — the slowest 1% of requests — might be 800 milliseconds. One in a hundred users is waiting almost a second.

Now apply that across a call chain. If you make five downstream calls per request and each has a 1% chance of hitting its p99, the probability that at least one of them is slow is roughly 5%. One in twenty of your users is now experiencing the compounded tail latency of your entire architecture. The more services in the chain, the worse this gets. At ten downstream calls, it's closer to 10%. This is tail latency amplification, and it's why microservice architectures that look fine in benchmarks can feel terrible in production.

Latency creates backpressure. A slow downstream doesn't just affect the requests waiting for it. It holds open connections, consumes goroutines, fills thread pools, and exhausts connection pools. One slow dependency can make your entire service appear slow — or worse, make it stop accepting new requests entirely. Latency is not just a user experience problem. It's a resource exhaustion problem.

Back to our booking system. A client submits a booking. The handler validates the request (fast), writes to the tenant's SQLite database (fast — it's local), sends a confirmation notification (variable — external dependency), and updates an availability index (network call to another node holding shared state). In development, this all completes in 5 milliseconds. In production, the notification service is in another region and the availability index is behind a load balancer. The median is 60 milliseconds. The p99 is 900 milliseconds because once or twice a minute, the notification service's TLS handshake overlaps with a garbage collection pause. One in a hundred bookings takes nearly a second, and the user sees a spinner.

The system isn't broken. It's just slow in a way that nobody designed for, because nobody felt it during development.

Measure Before You Guess

You cannot manage latency you cannot see. Record the duration of every outbound call — destination, method, time taken. The instrumentation is trivial; what matters is how you aggregate it.

Averages lie. A service with a 40-millisecond average might have a p99 of 800 milliseconds, and it's the p99 that your users experience when tail latency amplification kicks in across a call chain. You need percentile distributions — p50, p90, p95, p99 — for every dependency boundary. A structured log line per outbound call is enough if you don't have a metrics pipeline. The numbers will surprise you: the service you assumed was fast often isn't, and the one you worried about often is.

Latency Budgets

Once you can measure, you can budget. A latency budget is exactly what it sounds like: the total time a request is allowed to take, divided among the operations it performs.

If your user-facing endpoint needs to respond in under 200 milliseconds to feel responsive, that's your budget. Work backwards from there. The database query gets 20 milliseconds. The two downstream service calls get 50 milliseconds each. Serialisation and handler logic get 10 milliseconds. That leaves 70 milliseconds of slack — enough to absorb a moderate tail latency spike from one dependency without breaching the budget.

If the budget doesn't add up — if the sum of your dependencies' median latencies already exceeds your target — that's not a tuning problem. That's an architecture problem. You have too many sequential calls, and no amount of optimisation will fix it. You need fewer calls, parallel calls, or a different decomposition.

func (h *Handler) HandleBooking(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 200*time.Millisecond)
    defer cancel()

    booking, err := h.bookings.Create(ctx, req)
    if err != nil {
        respondError(w, err)
        return
    }

    // Notification and indexing can happen in parallel
    g, gctx := errgroup.WithContext(ctx)

    g.Go(func() error {
        return h.notifications.Send(gctx, booking)
    })

    g.Go(func() error {
        return h.index.Update(gctx, booking)
    })

    // If parallel calls fail, the booking still succeeded
    if err := g.Wait(); err != nil {
        // Log it, don't fail the response
        h.log.Warn("post-booking step failed", "err", err)
    }

    respondOK(w, booking)
}

Two things happened here. The notification and the index update moved from sequential to parallel — cutting the latency contribution from the sum of both to the maximum of either. And their failure became non-fatal to the response — if they're slow or broken, the user still gets their booking confirmation. The budget now has room to breathe.

Co-locate What Talks Together

The cheapest network call is the one you don't make. The second cheapest is the one that doesn't cross a network boundary.

If two components exchange data on every request, they should be as close together as possible — same process if feasible, same machine if not, same data centre at minimum. The booking handler and the tenant's SQLite database live on the same node by design. That's not an accident — it's a latency decision. The write path has zero network latency because there is no network.

This is why the per-tenant SQLite model has a structural advantage for latency-sensitive operations. The data is local. Reads and writes are system calls, not network calls. You pay the replication latency eventually, but not on the critical path.

When co-location isn't possible, reduce round trips. Batch multiple queries into a single call. Use projections to fetch only the fields you need. Every round trip you eliminate removes at least one latency tax.

// Bad: N+1 queries over the network
for _, id := range tenantIDs {
    tenant, err := client.GetTenant(ctx, id)  // network call per tenant
    results = append(results, tenant)
}

// Good: single batched call
tenants, err := client.GetTenants(ctx, tenantIDs)  // one network call

This looks obvious written down. In practice, the N+1 pattern creeps in constantly — especially in codebases where database access is hidden behind repository interfaces that only expose single-entity methods. The abstraction makes the network invisible, and invisible latency is the kind that multiplies.

Async by Default

The most effective latency strategy is removing work from the critical path entirely.

Any operation whose result the user doesn't immediately need should not block the response. Chapter 1 introduced the transactional outbox for reliability — queuing work so a downstream failure doesn't break the user's request. The same pattern applies here for a different reason: the downstream isn't unavailable, it's slow, and the user shouldn't wait.

The operations that belong in the outbox are often hiding in plain sight. One worth calling out: audit logging. It's almost universally synchronous, writing to a remote store or a separate database on every request. It adds 5–20 milliseconds to every call, it's invisible in profiling because it's spread across every endpoint, and it never needs to complete before the user gets a response. Move it to a local buffer that flushes asynchronously and you've just shaved latency off every request in your system.

The principle is simple: your response time should be determined by the work that matters to the user, not the bookkeeping that matters to you.

The Principle

Latency is not zero. It is finite, variable, and it compounds.

Measure it in percentiles, not averages. Budget for it deliberately. Parallelise where sequential chains multiply costs. Co-locate what talks together. Move everything non-essential off the critical path.

The speed of light is not getting faster. The number of network hops between your services is the variable you control.

Chapter 3: Bandwidth Is Infinite

It isn't. But it's the fallacy that feels most dated — surely, in an era of gigabit connections and cloud-scale infrastructure, bandwidth isn't the constraint it was in 1994?

The trap is in what you mean by bandwidth. Raw pipe capacity has grown enormously. What hasn't grown is the bandwidth available to your specific request at the specific moment it needs it, competing with every other request on the same network, through the same switches, to the same destination. Bandwidth is a shared, contended resource. Treating it as infinite doesn't mean your data won't get through — it means you'll never understand why things are slow, why your cloud bill is climbing, or why your system degrades under load.

What Actually Happens

Bandwidth waste manifests as three problems that look unrelated until you trace them to the same root cause.

Your system gets slower under load, and nobody can explain why. Individual requests are small. Individual responses are small. But multiply them by a thousand concurrent users and the aggregate traffic saturates a link somewhere — between your services, between your application and your database, between your data centre and your users. Each request is fine. The sum of all requests isn't. The monitoring shows healthy services with normal response times, because the bottleneck is in the network between them, and nobody's watching that.

Your cloud bill grows faster than your traffic. Cloud providers charge per gigabyte for data leaving their network — egress — and even for traffic between availability zones. If your payloads are bloated, you are paying for every unnecessary byte, thousands of times per second. Teams routinely discover that their egress bill exceeds their compute bill, and the fix is almost always "stop sending data you don't need." (Chapter 7 covers the full cost model in detail.)

Mobile and constrained clients suffer disproportionately. Your server-to-server links might be gigabit. Your user's phone on a train is sharing a congested cell tower with a few hundred other devices. The 2MB JSON response your API returns for a list view is a rounding error on your internal network and a three-second wait on a marginal connection. Bandwidth inequality between your infrastructure and your users' last mile is vast and getting worse as the payload sizes your infrastructure encourages grow faster than mobile network capacity improves.

The Quiet Tax: Payloads You Don't Notice

The biggest bandwidth waste isn't usually a single large transfer. It's the accumulation of small inefficiencies across thousands of requests.

Consider our booking system. A tenant admin requests a list of bookings. The API returns the full booking object for each result — customer details, payment information, internal notes, audit history, the full availability snapshot at the time of booking. The admin needed the booking reference, the customer name, and the date. The response is 40KB. It should be 2KB.

Multiply that by a paginated list that fetches 50 results, an auto-refresh every 30 seconds, and 200 concurrent admin users. You're now pushing 400KB per refresh, every 30 seconds, across 200 connections. That's roughly 16 megabytes per minute of data that nobody asked for and nobody uses. It's not enough to set off alarms. It's more than enough to degrade performance, inflate egress costs, and make the experience terrible for anyone not on a fast connection.

This is the over-fetching problem, and it's endemic in APIs that return full entity representations regardless of what the caller actually needs.

Send Less Data

The countermeasure is payload discipline: send what the caller needs and nothing more.

Projections. Let the caller specify which fields to return. This doesn't require GraphQL — a simple fields query parameter that controls serialisation is enough. The important thing is that the default response size is small, not that the mechanism is sophisticated.

func (h *Handler) ListBookings(w http.ResponseWriter, r *http.Request) {
    fields := parseFields(r.URL.Query().Get("fields"), defaultBookingFields)

    bookings, err := h.store.List(ctx, req.TenantID, req.Filters)
    if err != nil {
        respondError(w, err)
        return
    }

    respondJSON(w, project(bookings, fields))
}

// Default fields are the lightweight summary — callers opt in to more
var defaultBookingFields = []string{"id", "ref", "customer_name", "date", "status"}

The key decision is making the lightweight view the default. If callers have to opt out of heavy fields, they won't, and every client gets the full payload forever. If they have to opt in to additional fields, the default response stays small and callers who need more data ask for it explicitly.

Pagination. Never return unbounded collections. This sounds obvious, but "we'll add pagination later" is one of the most common statements in software, right up there with "we'll add tests later" and roughly as likely to happen. Every list endpoint gets a page size and a cursor from day one. The implementation cost is trivial. The cost of retrofitting pagination into a system where clients already depend on getting everything is not.

Compression. Enable gzip or zstd on your HTTP responses. JSON compresses well — a typical API response shrinks by 70–80%. This is a one-line configuration change in most HTTP servers and it's pure upside for text-based payloads. For the booking list example, that 40KB response drops to roughly 8KB even before you fix the over-fetching.

// Middleware — applied once, benefits everything
func compress(next http.Handler) http.Handler {
    return gziphandler.GzipHandler(next)
}

Compression doesn't fix the underlying design problem — you're still serialising data nobody needs, then compressing it — but it buys you time while you fix the real issue and immediately reduces egress costs.

Respect the Last Mile

Everything above applies between your services. Between your services and your users, the bandwidth gap described earlier — gigabit infrastructure versus a congested cell tower — makes every wasted byte hurt more.

Set a response size target for client-facing endpoints. A good rule of thumb: if a list view response exceeds 10KB compressed, question what's in it. If it exceeds 50KB, something is wrong. These aren't hard limits — they're triggers for scrutiny.

For systems that serve both desktop dashboards and mobile clients, content negotiation extends this further — serve different payload shapes from the same endpoint. The projection handler from above becomes:

func (h *Handler) ListBookings(w http.ResponseWriter, r *http.Request) {
    profile := negotiateProfile(r) // "compact" or "full"

    fields := profile.Fields             // summary fields vs full entity
    pageSize := profile.DefaultPageSize  // 10 for compact, 50 for full

    bookings, cursor, err := h.store.List(ctx, req.TenantID, fields, pageSize)
    if err != nil {
        respondError(w, err)
        return
    }

    respondJSON(w, bookings, cursor)
}

Compact profiles get fewer fields and smaller pages. Full profiles get the richer representation. The Accept header or a query parameter is enough — you don't need a separate API.

The users who suffer most from bandwidth assumptions are the ones least able to tell you about it. They don't file bug reports — they just leave.

Replication Is Traffic

In our SQLite-per-tenant model, every write to a tenant database on the primary needs to reach the secondary. That replication is network traffic, and it adds up.

A single tenant with moderate write volume might generate a few megabytes of WAL data per hour. Multiply that by a thousand tenants and you're shipping gigabytes of replication traffic across your network every hour. If primary and secondary are in different availability zones — which they should be for resilience — that's cross-AZ traffic with a per-gigabyte cost.

The countermeasures are practical. Batch replication rather than streaming every write immediately — a small delay in replication currency buys a large reduction in network overhead. Compress the replication stream. And critically, be selective about what replicates synchronously versus what can replicate on a schedule. Hot data — active bookings, current availability — needs to replicate quickly. Cold data — completed bookings, archived records — can replicate in batched sweeps during off-peak hours.

This is a bandwidth budget, applied to replication the same way Chapter 2 applied a latency budget to request handling. The total replication bandwidth is finite. Spend it on the data that matters most.

Telemetry: The Observer's Tax

Observability infrastructure generates network traffic. Lots of it.

Every structured log line, every metric data point, every distributed trace span is a payload that crosses the network to reach your logging pipeline, your metrics store, your trace collector. In a system instrumented thoroughly enough to debug production issues — which it needs to be — the telemetry traffic can rival the application traffic.

This is not an argument against observability. It's an argument for being intentional about it. Sample traces rather than capturing every request. Aggregate metrics client-side and flush in batches rather than sending individual data points. Set log levels that are appropriate for production — debug logging in production is a bandwidth tax disguised as diligence. Buffer and compress telemetry payloads before shipping them.

The irony is that the tools you use to detect bandwidth problems can themselves be the bandwidth problem. Watch for it.

The Principle

Bandwidth is not infinite. It is shared, contended, and priced.

Send less data: project what the caller needs, paginate by default, compress everything. Treat replication as traffic with a budget, not as a background process you can ignore. Be as intentional about telemetry volume as you are about application traffic.

Every byte you put on the wire has a cost in latency under contention, in money at the cloud provider's egress meter, and in capacity that isn't available to the next request. The cheapest byte is the one you never send.

Chapter 4: The Network Is Secure

It isn't. And unlike the previous three fallacies, this one can end your business.

Network reliability, latency, and bandwidth are engineering problems. When you get them wrong, systems slow down or produce errors. When you get security wrong, customer data leaks, regulatory fines land, and trust — the thing that takes years to build — evaporates in an afternoon.

The persistence of this fallacy isn't that engineers believe networks are literally secure. It's that they believe their network is secure — that the firewall, the VPC, the private subnet, the "internal" label on the architecture diagram means the traffic inside the perimeter can be trusted. It can't. The perimeter is not a wall. It's a speed bump.

The Perimeter Is Dead

The traditional security model is a castle: hard exterior, soft interior. A firewall separates the trusted internal network from the hostile external one. Traffic that crosses the boundary gets scrutinised. Traffic inside the boundary is trusted.

This model assumes that the network has an inside and an outside, and that the inside is safe. Both assumptions are wrong.

The inside is not safe because attackers who breach the perimeter — through a phishing email, a compromised dependency, a misconfigured public endpoint — find themselves in a network where nothing checks their credentials. Services talk to each other over plain HTTP. Databases accept connections from any internal IP. Secrets sit in environment variables or config files readable by any process on the machine. The hard exterior means nothing once you're past it.

The inside doesn't even exist in the way it used to. Your services run across multiple cloud regions. Your developers work from home networks. Your CI/CD pipeline runs in a third-party provider's infrastructure. Your SaaS dependencies communicate with your systems from their own networks. The boundary between "internal" and "external" is a polite fiction drawn on a diagram that no longer maps to reality.

Zero trust is not a product you buy. It is the recognition that every network is hostile — including yours — and the decision to design accordingly.

What a Breach Looks Like

Consider our booking system. The notification service — a non-critical component, possibly a third-party integration, the kind of thing that sits in the outbox path from Chapter 1 — is compromised. Maybe a dependency had a vulnerability. Maybe a credential was leaked. It doesn't matter how. What matters is what happens next.

In a perimeter-security model, the answer is: everything. The notification service is inside the network. It can reach the booking service over plain HTTP. It can forge requests with any tenant ID. It can connect directly to tenant databases if it can reach the filesystem or the database port. It can read environment variables containing encryption keys. From one compromised non-critical service, the attacker has a path to every tenant's data.

Now consider the same breach with layered defences. The notification service tries to reach the booking service — but it can't authenticate because it doesn't have a valid mTLS certificate for that service boundary. It tries to connect to the tenant database port — but network policies block it because the notification service was never allowed that path. It tries to read a tenant database file directly — but the file is encrypted with a key that lives in a secrets store the notification service has no access to. It tries to forge a booking request to an endpoint it can reach — but per-request authorisation rejects it because its identity doesn't have access to the tenant it's targeting.

Four layers. Each one independently sufficient to block or contain this specific attack. Together, they turn a compromised service from a total breach into an isolated incident. That's what zero trust looks like in practice: not one wall, but depth.

Trust No Input

This is the foundational zero-trust behaviour. Everything else follows from it.

Every request that arrives over the network is untrusted — not just requests from external users, but requests from other services. A compromised upstream will send you perfectly well-formed requests that do things you don't want. Authenticate and authorise at every boundary, every time.

func (h *Handler) GetBooking(w http.ResponseWriter, r *http.Request) {
    // Authenticate: who is making this request?
    identity, err := h.auth.Verify(r)
    if err != nil {
        respondUnauthorized(w)
        return
    }

    // Authorise: can this identity access this tenant's data?
    tenantID := chi.URLParam(r, "tenantID")
    if !identity.CanAccess(tenantID) {
        respondForbidden(w)
        return
    }

    bookingID := chi.URLParam(r, "bookingID")
    booking, err := h.store.Get(r.Context(), tenantID, bookingID)
    if err != nil {
        respondError(w, err)
        return
    }

    respondOK(w, booking)
}

Authentication proves who is making the request. Authorisation proves they're allowed to make it. Both checks, on every request, at every service boundary. The internal network is not an authentication mechanism.

Encrypt in Transit

If you trust nothing, you encrypt everything — not just the traffic that crosses the internet, but the traffic between your own services.

Between your services and the internet: TLS. Terminate at your edge, enforce HTTPS, redirect HTTP, set HSTS headers. This is table stakes.

Between your services: mTLS. If Service A calls Service B over the internal network, that traffic should be encrypted and both sides should present certificates that prove their identity. This is the layer that stopped the compromised notification service from impersonating a legitimate caller — without a valid certificate, it can't participate.

func newMTLSClient(certFile, keyFile, caFile string) (*http.Client, error) {
    cert, err := tls.LoadX509KeyPair(certFile, keyFile)
    if err != nil {
        return nil, fmt.Errorf("load client cert: %w", err)
    }

    caCert, err := os.ReadFile(caFile)
    if err != nil {
        return nil, fmt.Errorf("read CA cert: %w", err)
    }

    caPool := x509.NewCertPool()
    caPool.AppendCertsFromPEM(caCert)

    return &http.Client{
        Transport: &http.Transport{
            TLSClientConfig: &tls.Config{
                Certificates: []tls.Certificate{cert},
                RootCAs:      caPool,
            },
        },
    }, nil
}

mTLS gives you encryption (nobody can read the traffic) and authentication (both sides prove who they are). A service without a valid certificate can't connect, even if it's on the same network. Not paranoia — engineering.

Encrypt at Rest and Protect the Keys

Data on disk is data that can be read by anyone who gains access — through a compromised process, a stolen backup, a decommissioned drive that wasn't wiped, or a cloud provider's employee with infrastructure access.

In our booking system, each tenant's SQLite database is a single file. That file contains everything: customer names, contact details, payment references, booking history. If that file is readable, the tenant's data is exposed. SQLCipher provides transparent AES-256 encryption — the database is encrypted at rest, decrypted in memory during use.

func openTenantDB(dbPath string, key []byte) (*sql.DB, error) {
    db, err := sql.Open("sqlite3", dbPath)
    if err != nil {
        return nil, err
    }

    pragma := fmt.Sprintf("PRAGMA key = \"x'%s'\";", hex.EncodeToString(key))
    if _, err := db.Exec(pragma); err != nil {
        db.Close()
        return nil, fmt.Errorf("set encryption key: %w", err)
    }

    return db, nil
}

But encryption is only as strong as the key management. AES-256 is not getting broken. The question is where the keys live — and the answer determines whether your encryption is a real defence or theatre.

The threat model is simple: if someone compromises the application server, can they read the database? If the encryption key is on the same machine in an environment variable, the answer is yes. The compromised notification service from our scenario would have the key and the file.

The principle is separation: the system that runs your application should not be the system that stores your secrets. Keys live in a dedicated secrets store — HashiCorp Vault, AWS KMS, or a separate encrypted configuration deployed through a different channel. Each service retrieves only the keys it needs at runtime, scoped to its identity.

func (s *Service) Init(ctx context.Context) error {
    key, err := s.vault.GetSecret(ctx, "tenant-db-encryption-key")
    if err != nil {
        return fmt.Errorf("retrieve encryption key: %w", err)
    }
    s.dbKey = key
    return nil
}

The notification service has no access to the tenant database encryption keys because it has no reason to. Scoping secrets to the narrowest possible audience means a breach of one service doesn't hand the attacker the keys to everything else.

Contain the Blast Radius

Each layer so far prevents a specific attack. Network-level least privilege prevents the attacks from being attempted in the first place.

Every service can reach only what it needs to reach. Not "every service can reach every other service because they're all in the same VPC." Network policies should be explicit allowlists: the booking service can reach the tenant database (local) and the payment provider (port 443). It cannot reach the admin dashboard, the CI/CD pipeline, or services it has no business talking to.

The notification service in our scenario has an allowlist too: it can reach the email delivery API. That's it. It cannot reach tenant databases, the booking service's internal ports, or the secrets store. When it's compromised, the attacker has access to one outbound path — the email API. The blast radius is one service's scope, not the entire network.

This is unglamorous work. Maintaining fine-grained network policies is tedious, and the temptation to open wide rules "just for now" is constant. Resist it. Every wide-open rule is a path an attacker can use, and "just for now" has a half-life measured in years.

The Principle

The network is not secure. Yours isn't either.

Trust no input — authenticate and authorise every request at every boundary. Encrypt in transit — between your services, not just at the edge. Encrypt at rest — and keep the keys separate from the data they protect. Contain the blast radius — restrict every service to the minimum network access it needs.

Each layer is independently valuable. Together, they turn a breach of one component into an incident report rather than a catastrophe. That's zero trust: not a product, not a vendor, not a checkbox. An architectural decision to stop pretending that any network — including the one you built — is safe.

Chapter 5: Topology Doesn't Change

It does. Constantly.

The network you deployed to this morning is not the network your system is running on right now. Instances have been replaced. Load balancers have shifted traffic. An auto-scaler has added nodes in one region and removed them in another. A cloud provider has migrated your workload to different physical hardware without telling you. The IP address your service was talking to an hour ago may no longer exist.

This is not failure. This is normal operation.

The Static Assumption

Engineers embed topology into systems without realising they're doing it. A database connection string with an IP address. A config file with a hardcoded service endpoint. A deployment script that assumes three nodes in a specific arrangement. A health check that monitors a specific host rather than a service.

These all encode an assumption: that the system's physical layout is fixed. That the database will always be at 10.0.1.5. That the payment service will always be at payments.internal:8080. That there will always be exactly three replicas, and they'll always be in these availability zones.

In a world of physical servers racked in your own data centre, these assumptions held for months or years at a time. Topology changes were planned events — you scheduled downtime, updated configs, restarted services. The assumption wasn't wrong; it was just fragile in a way that didn't matter when changes were rare.

In a cloud environment, topology changes are continuous. Instances are ephemeral — they're created, destroyed, and replaced as a matter of routine. Auto-scaling adds and removes capacity based on load. Rolling deployments replace nodes one at a time. Availability zone failures redirect traffic. Spot instances disappear with two minutes' notice. The topology is not just different from what you planned — it's different from what it was ten minutes ago.

What Actually Happens

Services move and clients don't notice — until they do. A service restarts on a different node with a different IP. DNS propagates the change, but the calling service has cached the old address. For the next five minutes — or thirty, depending on TTL and client behaviour — requests go to an IP that no longer has anything listening on it. The calling service sees connection timeouts, triggers its retry logic, and either recovers when the cache expires or exhausts its retries and fails.

Scaling changes the topology under load. The auto-scaler adds two new instances to handle a traffic spike. The load balancer starts routing traffic to them. But these instances have cold caches, unprimed connection pools, and haven't finished their startup health checks. For a window, a percentage of requests hit nodes that aren't fully ready, producing higher latency or errors — exactly when the system is already under stress.

Failover changes everything at once. An availability zone goes down. The orchestrator moves workloads to surviving zones. Database replicas promote. DNS records update. Load balancer targets change. Every service in the affected zone restarts somewhere else, simultaneously, with new IPs, new network paths, and new latency characteristics. This is not a topology change — it's a topology replacement.

In our booking system, a tenant's SQLite database lives on a specific node. That node fails. The orchestrator reschedules the workload to a new node. The database file needs to be there — either restored from the replica or reattached from persistent storage. The service discovers its new location. Every other service that was talking to the old node needs to find the new one. The tenant's users, mid-session, need to be routed to the node that now has their data.

If any part of this chain has a hardcoded assumption about where things are, it breaks.

Don't Hardcode Anything

The first and most fundamental countermeasure is removing static topology from your system entirely.

No IP addresses in configuration. No hostnames baked into binaries. No assumptions about the number of instances, their locations, or their availability zones. Every reference to another component should go through a layer of indirection that can absorb topology changes without requiring code changes or restarts.

DNS is the most common indirection layer, and it works — with caveats. DNS records have TTLs, and clients cache them. If your service moves to a new IP and the DNS record updates, clients won't see the change until their cached entry expires. Set TTLs appropriately for your environment: low enough that changes propagate quickly (30–60 seconds is common for internal services), high enough that you're not hammering your DNS server on every request.

// Use the service name, never the IP
const bookingServiceAddr = "bookings.internal:443"

// Ensure the HTTP client respects DNS TTL
func newClient() *http.Client {
    dialer := &net.Dialer{
        Timeout: 5 * time.Second,
    }
    transport := &http.Transport{
        DialContext:         dialer.DialContext,
        MaxIdleConns:        100,
        IdleConnTimeout:     90 * time.Second,
        // Don't cache connections forever — allow DNS changes to take effect
        MaxIdleConnsPerHost: 10,
    }
    return &http.Client{Transport: transport}
}

The subtle problem is persistent connections. HTTP keep-alive, gRPC channels, database connection pools — these maintain long-lived connections to specific IPs. When the target moves, the connection is still open to the old address. Either the connection fails (and the retry logic creates a new one to the right place) or worse, it stays open to a recycled IP that's now a different service entirely.

The fix is connection lifecycle management. Set maximum connection ages. Drain connections gracefully during deployments. Configure your connection pools to periodically close and re-establish connections so that DNS changes are picked up even when traffic is flowing normally.

Service Discovery

For systems beyond a handful of services, DNS alone isn't enough. Service discovery provides a registry of what's running, where it is, and whether it's healthy.

The implementation can be as simple as a shared database table:

type ServiceInstance struct {
    ServiceName string
    InstanceID  string
    Address     string
    Port        int
    Healthy     bool
    LastSeen    time.Time
}

func (r *Registry) Register(ctx context.Context, inst ServiceInstance) error {
    return r.db.ExecContext(ctx,
        `INSERT INTO service_registry (service_name, instance_id, address, port, healthy, last_seen)
         VALUES (?, ?, ?, ?, ?, ?)
         ON CONFLICT (instance_id) DO UPDATE SET
            address = excluded.address,
            port = excluded.port,
            healthy = excluded.healthy,
            last_seen = excluded.last_seen`,
        inst.ServiceName, inst.InstanceID, inst.Address, inst.Port, inst.Healthy, time.Now(),
    )
}

func (r *Registry) Resolve(ctx context.Context, serviceName string) ([]ServiceInstance, error) {
    rows, err := r.db.QueryContext(ctx,
        `SELECT instance_id, address, port FROM service_registry
         WHERE service_name = ? AND healthy = true
         AND last_seen > ?`,
        serviceName, time.Now().Add(-30*time.Second),
    )
    // ... scan and return instances
}

Each service registers itself on startup and updates its entry on a heartbeat. Callers resolve the service name to a list of healthy instances and pick one. When an instance moves, it re-registers with its new address. When an instance dies, its heartbeat stops and it drops out of the registry.

This is not Consul. It's not etcd. It's not Kubernetes service discovery. It's a database table with an upsert and a query, and for many systems it's all you need. The point is the pattern — indirection between "I need to talk to the booking service" and "the booking service is at this IP" — not the specific technology.

Design for Graceful Handover

Topology changes are routine. Your system should treat them that way.

When a service instance is shutting down — for a deployment, a scale-down, or a node migration — it should stop accepting new requests, finish processing in-flight requests, deregister from service discovery, and then exit. This is graceful shutdown, and Go makes it straightforward:

func main() {
    srv := &http.Server{Addr: ":8080", Handler: router}

    go func() {
        if err := srv.ListenAndServe(); err != http.ErrServerClosed {
            log.Fatal(err)
        }
    }()

    // Wait for interrupt signal
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
    <-quit

    // Deregister from service discovery
    registry.Deregister(context.Background(), instanceID)

    // Give in-flight requests time to complete
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    srv.Shutdown(ctx)
}

The deregistration happens before the shutdown. This means new requests stop arriving (because the load balancer or service discovery no longer includes this instance) while existing requests finish naturally. There's a window — the time between deregistration and the last in-flight request completing — where the instance is draining. Size that window appropriately for your workload.

For our booking system, graceful handover has an additional concern: the tenant databases. If a node is shutting down, the SQLite databases it hosts need to be fully synced to the replica before the node exits. The shutdown sequence becomes: deregister, drain HTTP requests, flush pending replication, close database connections, exit. Each step has a timeout. If replication can't complete in time, the replica may be slightly behind — but the data is still on the primary's persistent storage and can be recovered.

Health Checks That Mean Something

A health check that returns 200 OK because the process is running is not a health check. It's a process check. It tells you the binary started. It doesn't tell you the service can do its job.

A meaningful health check verifies the things that matter: can the service reach its database? Can it reach its critical dependencies? Is it able to accept and process requests? If any of these are false, the health check should fail, and the service should be removed from the load balancer and service discovery until it recovers.

func (h *Handler) HealthCheck(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()

    if err := h.db.PingContext(ctx); err != nil {
        w.WriteHeader(http.StatusServiceUnavailable)
        json.NewEncoder(w).Encode(map[string]string{
            "status": "unhealthy",
            "reason": "database unreachable",
        })
        return
    }

    w.WriteHeader(http.StatusOK)
    json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
}

Health checks interact with topology changes in a critical way. When a node moves, the new instance needs to pass its health check before receiving traffic. If the health check is trivial — just "is the process running?" — the load balancer will send requests to an instance that hasn't finished connecting to its database or loading its tenant data. If the health check is meaningful, the instance only receives traffic when it's genuinely ready.

This is the difference between a deployment that's invisible to users and one that produces a burst of errors every time you push code.

The Principle

Topology is not static. It is a variable, not a constant.

Remove hardcoded topology from your system — no IPs in config, no assumptions about instance counts, no baked-in knowledge of where things are. Use DNS with appropriate TTLs or service discovery to decouple service identity from service location. Design for graceful handover so that topology changes are routine, not disruptive. And make your health checks meaningful so that traffic only reaches instances that can actually serve it.

The network your system is running on right now is not the one you deployed to. Design for the one it will be running on in ten minutes.

Chapter 6: There Is One Administrator

There isn't. There never was, and the illusion that there is may be the most dangerous fallacy of all.

The previous five fallacies are about physics and engineering — the properties of networks that constrain what's possible. This one is about people and organisations. It's about the assumption that someone, somewhere, is in control of the whole system. That there's a single authority who can see everything, change anything, and coordinate across all the moving parts.

In 1994, when Deutsch wrote this down, it meant you couldn't assume one operations team controlled the entire network path between your systems. In 2026, it means something far more expansive: your system exists at the intersection of dozens of organisations, each with their own priorities, their own change schedules, their own incident response, and their own definition of "working correctly." None of them report to you. Most of them don't know you exist.

The Dependency Web

Draw a map of everyone who can break your system without asking your permission.

Your cloud provider can deprecate an API, change pricing, modify rate limits, or suffer an outage in your region. Your DNS provider can have a bad day and take your domain resolution with it. Your certificate authority can delay a renewal. Your payment processor can change their webhook format. Your email delivery service can alter their rate limits. Your CDN can purge a cache or change a routing policy. Your container registry can go down during a deployment.

None of these require a failure on your part. None of them require a breach of contract — most SLAs are written broadly enough that a multi-hour outage is within the terms. All of them can take your system down, and for most of them, your only option is to wait.

Now add the internal dimension. In any organisation beyond a small team, different services are owned by different teams. Team A deploys on Tuesdays. Team B deploys continuously. Team C hasn't deployed in three months and is running a version of their service that depends on an API you deprecated in January. The database team has a maintenance window on Sunday nights. The security team just pushed a firewall rule change that blocks traffic from a new subnet. The platform team upgraded the Kubernetes cluster and broke the ingress controller configuration.

There is no single administrator. There is a web of partially-overlapping authorities, each controlling a piece of the system, each operating on their own schedule, and each making changes that are locally reasonable and globally unpredictable.

What Actually Happens

Vendor changes break your system on their schedule, not yours. A payment provider updates their API. The change is documented in a changelog you didn't read, announced in an email that went to a distribution list you're not on, and deployed on a Tuesday afternoon while your team is focused on their own release. Your payment integration starts returning errors. You discover the breaking change through your error logs, not through any coordinated communication.

SLAs promise less than you think. A 99.9% uptime SLA allows 8.7 hours of downtime per year. That's not a guarantee of reliability — it's a ceiling on the refund you can claim. And the refund is typically service credits, not compensation for the revenue you lost, the customers who churned, or the incident response hours your team burned. Your vendor's SLA is a financial instrument, not an engineering commitment. Your system's actual reliability is bounded by the least reliable dependency you can't work around.

Change windows collide. Your deployment pipeline depends on a container registry, a cloud provider's API, a DNS service, and an artifact store. Any one of them being degraded during your deployment means the deployment fails or partially succeeds — which is worse. You don't coordinate change windows with these providers because you can't. They don't coordinate with each other because they have no reason to. The probability that everything you depend on is fully operational at any given moment decreases with every dependency you add.

In our booking system, the critical path for a booking involves the application (you control this), the tenant's SQLite database (you control this), the payment provider (you don't), and the notification service which depends on an email delivery API (you really don't). If Stripe changes their webhook signing scheme or SendGrid has a regional outage, your booking flow is affected regardless of how well your own code works. You have two dependencies you control and two you don't, and the ones you don't control are the ones that touch money and customer communication.

Own Your Critical Path

The most important architectural decision in a multi-administrator world is identifying what you must own and what you can afford to depend on.

Your critical path is the minimum set of operations that must succeed for your system to deliver its core value. For the booking system, that's: accept the booking, store it durably, process the payment. Everything else — notifications, analytics, index updates — is important but not critical. The system is degraded without them; it's broken without the critical path.

Every component on the critical path that you don't control is a point where someone else's decision can break your system. The countermeasure isn't eliminating all dependencies — that's not practical. It's minimising the dependencies on your critical path and having a plan for when the remaining ones fail.

func (s *BookingService) CreateBooking(ctx context.Context, req BookingRequest) (*Booking, error) {
    // Step 1: Store the booking — entirely under our control
    booking, err := s.store.Create(ctx, req)
    if err != nil {
        return nil, fmt.Errorf("store booking: %w", err)
    }

    // Step 2: Process payment — external dependency, critical
    payment, err := s.payments.Charge(ctx, booking)
    if err != nil {
        // Payment failed — roll back the booking
        _ = s.store.Cancel(ctx, booking.ID)
        return nil, fmt.Errorf("payment: %w", err)
    }

    // Step 3: Record the payment reference — back under our control
    booking.PaymentRef = payment.Ref
    if err := s.store.Confirm(ctx, booking); err != nil {
        // We've charged the customer but can't confirm — this needs manual attention
        s.alerts.Critical(ctx, "payment confirmed but booking update failed",
            "booking_id", booking.ID, "payment_ref", payment.Ref)
        return nil, fmt.Errorf("confirm booking: %w", err)
    }

    // Everything below this line is non-critical — queue it
    s.outbox.Enqueue(ctx, "send_confirmation", booking.ID)
    s.outbox.Enqueue(ctx, "update_availability", booking.ID)
    s.outbox.Enqueue(ctx, "notify_analytics", booking.ID)

    return booking, nil
}

The code makes the architectural decision visible. Steps 1 and 3 are fully under your control — local database operations. Step 2 is the one external dependency on the critical path, and it has explicit rollback logic. Everything after step 3 is queued through the outbox and can tolerate the notification service, the analytics pipeline, or any other dependency being down. The critical path has exactly one external dependency. Everything else is decoupled.

Build for Vendor Failure

The dependencies you can't eliminate need to be wrapped in abstractions that let you survive their failures.

Abstraction layers. Don't call Stripe directly from your booking handler. Call a payment interface that Stripe implements. When Stripe changes their API, you update one adapter. When Stripe goes down and you need to fail over to a backup processor, you swap the implementation. The booking handler never knows.

type PaymentProcessor interface {
    Charge(ctx context.Context, amount Money, customer Customer) (*Payment, error)
    Refund(ctx context.Context, paymentRef string) error
}

// Primary implementation
type StripeProcessor struct { /* ... */ }

// Fallback — or a future replacement
type SquareProcessor struct { /* ... */ }

This is not speculative over-engineering. It's the structural acknowledgement that you don't control this dependency and the person who does will change it without consulting you.

Cache what you can't afford to fetch. If your system needs data from an external service to function — exchange rates, product catalogues, configuration from a third-party API — cache it locally with a staleness threshold you can live with. When the external service is down, you serve stale data rather than failing. The user gets slightly outdated exchange rates rather than an error page. Make the staleness visible — log it, show it in the admin dashboard — but don't let it become a hard failure.

Timeouts and circuit breakers take on new meaning here. In Chapter 1, these patterns protect against network failures. In a multi-administrator context, they're your early warning system for problems you can't see — another team's bad deployment, a vendor's undisclosed incident. The circuit breaker that opens against your payment provider at 2pm on a Tuesday is telling you something their status page won't say for another hour.

Know What You Depend On

You cannot manage dependencies you haven't enumerated.

Maintain a dependency inventory — a living document that lists every external service your system relies on, what it's used for, whether it's on the critical path, what happens if it's unavailable, and when its contract or API version was last reviewed.

This sounds bureaucratic. It's not. It's the document you reach for at 2am when something is broken and you need to know whether the payment provider's status page is relevant to your outage. It's the document that tells you which vendor's deprecation email you need to take seriously. It's the document that makes the multi-administrator reality visible instead of implicit.

Review it quarterly. When you add a new dependency, add it to the inventory before you ship the integration. When a vendor has an incident that affects you, update the inventory with what you learned. Over time, it becomes the institutional memory of how your system interacts with the systems you don't control.

The Principle

There is no single administrator. Your system spans organisational boundaries you cannot see and cannot control.

Identify your critical path and minimise the external dependencies on it. Wrap the dependencies you can't eliminate in abstractions that let you survive their failures. Cache what you can't afford to lose access to. And maintain an inventory of what you depend on, because the web of administrators affecting your system is larger than you think.

Your vendor's SLA is their promise to themselves. Your system's reliability is your promise to your users. Design accordingly.

Chapter 7: Transport Cost Is Zero

It isn't. And this may be the fallacy the industry is most actively, expensively wrong about right now.

Transport cost is not just latency — Chapter 2 covered that. It's not just bandwidth — Chapter 3 covered that. It's the total cost of moving data across a network boundary: the money you pay to your cloud provider for every byte that leaves their network, the CPU cycles spent serialising and deserialising payloads, the engineering time spent debugging failures that only exist because two things are talking over a network instead of a function call, and the operational complexity of running, monitoring, and securing every connection between every service.

Every network call has a price. The industry's prevailing architecture — fine-grained microservices communicating over HTTP — maximises the number of network calls and treats each one as free. It is not free. It has never been free.

The Three Costs

Transport cost operates on three axes simultaneously, and optimising for one often worsens another.

Money. Cloud egress — data leaving a provider's network — is priced per gigabyte. AWS charges roughly $0.09 per GB for internet egress. Inter-region traffic is $0.01–0.02 per GB. Even inter-AZ traffic within the same region has a cost. These numbers look small until you multiply them by the volume of a production system.

A microservices architecture with twenty services, each making an average of ten calls to other services per request, at a modest 5KB per response, handling a thousand requests per second: that's 100,000 inter-service calls per second, each carrying 5KB. Roughly 500MB per second of internal traffic. Over a month, that's about 1.3 petabytes. Even at inter-AZ rates, you're looking at a five-figure monthly bill — just for your services talking to each other, before a single byte reaches a user.

These numbers are not unusual. They are the natural consequence of treating transport as free and decomposing aggressively.

CPU. Every network call requires serialisation on one side and deserialisation on the other. JSON marshalling is not free — it involves reflection, memory allocation, and string processing. For a service that handles thousands of requests per second, serialisation overhead can consume a meaningful percentage of CPU. You're spending compute turning structs into bytes, sending those bytes over a network, and then turning them back into structs — work that a function call would have accomplished with a pointer.

// This is a function call. Cost: nanoseconds, zero allocation.
result := bookingService.Create(ctx, req)

// This is the same operation over a network boundary.
// Cost: serialise req to JSON, HTTP request, TLS handshake (or connection reuse),
// transmit bytes, deserialise on the far side, process, serialise response,
// transmit back, deserialise response. Microseconds to milliseconds,
// multiple allocations, error handling for network failures.
body, _ := json.Marshal(req)
resp, _ := http.Post("https://bookings.internal/api/v1/bookings", "application/json", bytes.NewReader(body))
var result Booking
json.NewDecoder(resp.Body).Decode(&result)

The two code blocks do the same thing. One costs nanoseconds and no allocations. The other costs milliseconds, multiple allocations, CPU for serialisation on both sides, and introduces an entire category of failure modes that the function call doesn't have. The question is not whether the network version works — it does. The question is whether the boundary is justified.

Complexity. Every network boundary is a failure boundary. It needs timeouts, retries, circuit breakers, error handling for partial failures, idempotency for safe retries, monitoring, alerting, and security. The preceding six chapters of this book are essentially a manual for handling those concerns — and each one applies at every network boundary in your system. Twenty services with ten inter-service calls each means two hundred network boundaries, each requiring the full complement of resilience patterns.

This is the hidden cost — not the money or the CPU, but the engineering time spent building, testing, and maintaining resilience logic for boundaries that didn't need to exist. Every unnecessary network call is a call that needs retry logic it wouldn't need if it were a function call, timeout handling it wouldn't need if it were in-process, and monitoring it wouldn't need if it couldn't fail independently.

The Decomposition Trap

The microservices movement told the industry that small, independently deployable services were the correct default architecture. For some systems, at some scales, with some team structures, that's true. For most systems, it's an expensive mistake driven by convention rather than analysis.

The question that should precede every service boundary is: does this boundary pay for itself?

A service boundary pays for itself when the components on either side have genuinely different scaling requirements, different deployment cadences, different team ownership, or different technology needs. A search indexer that needs to scale independently of the booking service? Separate service, justified. A notification sender that deploys on a different schedule because a different team owns it? Separate service, justified.

A booking validator separated from the booking writer because "single responsibility principle"? Not justified. A service that exists solely to wrap a database table? Not justified. These boundaries add cost — transport cost, complexity cost, operational cost — and deliver nothing that a well-structured module boundary within a single service wouldn't provide.

// Monolith with clear internal boundaries — same isolation, no network cost
package main

import (
    "booking/internal/bookings"
    "booking/internal/availability"
    "booking/internal/notifications"
)

func main() {
    store := bookings.NewStore(db)
    avail := availability.NewChecker(db)
    notify := notifications.NewSender(mailer)

    handler := api.NewHandler(store, avail, notify)
    http.ListenAndServe(":8080", handler)
}

Three packages, clear interfaces, separate concerns, independently testable. Zero network calls between them. Zero serialisation overhead. Zero retry logic. Zero additional failure modes. If these need to become separate services later — because traffic patterns demand it or team ownership requires it — the package interfaces become the service API contracts. The decomposition path is there when you need it. Taking it before you need it is paying transport cost for nothing.

The Booking System's Advantage

Our per-tenant SQLite model has a structural advantage here that's worth making explicit.

In a typical microservices booking system, a single booking might involve: a call to the user service (who is this customer?), a call to the availability service (is this slot free?), a call to the pricing service (what does it cost?), a call to the payment service (charge them), a call to the booking service (store the booking), and a call to the notification service (tell them about it). Six network calls, six sets of transport costs, six failure boundaries.

In the SQLite-per-tenant model, the user data, the availability data, the pricing rules, and the booking itself are all in the tenant's database — on the same node, in the same process. The check-and-book operation is a database transaction, not a distributed choreography. The only network calls on the critical path are the ones that genuinely cross an organisational boundary: the payment provider (external, unavoidable) and the replication to the secondary (internal, required for durability).

Two network calls instead of six. Two failure boundaries instead of six. Two sets of transport costs instead of six. The system is simpler not because it does less, but because it puts the data where the work happens instead of scattering it across network boundaries.

This isn't an argument against distributed systems. It's an argument against unnecessarily distributed systems — systems that pay transport costs for decomposition that serves convention rather than need.

When to Split

Since this chapter argues against gratuitous decomposition, it's worth being explicit about when splitting is the right call.

Different scaling profiles. If one component needs 50 instances under load and another needs 2, running them in the same process wastes resources. The transport cost of the network boundary is less than the compute cost of scaling them together.

Different team ownership. If separate teams own separate components and need to deploy independently without coordinating, a service boundary with a versioned API contract is the mechanism that enables that independence. The transport cost is the price of organisational autonomy.

Different failure domains. If a component's failure should be isolated from the rest of the system — a video transcoder that might consume all available memory, a batch processor that might saturate CPU — a process boundary provides the isolation. The network boundary is incidental; the failure boundary is the point.

Different technology requirements. If a component genuinely needs a different language, runtime, or database — an ML inference engine in Python alongside a Go API server — a service boundary is the integration mechanism.

In each case, the boundary exists because there's a specific, articulable reason that outweighs the transport cost. "It's cleaner" is not that reason. "Microservices are best practice" is not that reason. A transport cost paid without a corresponding benefit is waste.

The Principle

Transport is not free. Every network call costs money, CPU, and complexity.

The cost compounds with every boundary in your system. Twenty services talking to each other aren't twenty simple things — they're two hundred network boundaries, each carrying the full weight of failure handling, monitoring, and operational overhead.

The default should be a well-structured monolith with clear internal boundaries. Decompose when the cost of the boundary is less than the cost of keeping things together — different scaling needs, different teams, different failure domains. And measure the cost before you split, not after.

The cheapest network call, like the cheapest byte, is the one you never make.

Chapter 8: The Network Is Homogeneous

It isn't. But it's easy to believe it is, because we've papered over the differences so thoroughly that most engineers never encounter them — until they do, and the failure makes no sense.

In 1994, when Gosling added this fallacy, heterogeneity was obvious. Systems ran different operating systems, spoke different protocols, used different byte orderings, and encoding mismatches were a daily reality. The modern stack feels uniform by comparison: everything runs Linux, everything speaks HTTP, everything serialises as JSON, everything deploys in containers. Surely the network is, if not homogeneous, close enough?

It is not close enough. The heterogeneity has moved, not disappeared. It lives in the details that the uniform surface conceals: different HTTP implementations with different timeout behaviours, different JSON parsers with different number precision, different TLS libraries with different cipher suite preferences, different clocks with different drift rates, different proxy behaviours, different load balancer quirks, and different interpretations of the same RFC by different vendors.

Where the Differences Hide

Numbers are not universal. JSON has one number type. JavaScript — the language JSON was designed for — represents all numbers as IEEE 754 double-precision floats. A 64-bit integer like 9007199254740993 loses precision when parsed by a JavaScript client because it exceeds the safe integer range. Your Go service sends a precise ID. The JavaScript frontend receives a different number. No error. No warning. Just wrong data.

// Go: this is an exact 64-bit integer
type Booking struct {
    ID int64 `json:"id"` // 9007199254740993
}

// JavaScript: JSON.parse produces 9007199254740992
// The last digit changed. Silently.

The fix is well-known — use strings for large identifiers — but the point isn't the fix. The point is that two systems speaking "the same" format (JSON over HTTP) silently disagree about the meaning of a value because their underlying representations are different. The network looks homogeneous. The semantics are not.

Time is not universal. Every machine has a clock. No two clocks agree. NTP keeps them close — typically within a few milliseconds — but "close" is not "identical," and in a distributed system, the difference matters.

If Service A timestamps an event at 14:00:00.003 and Service B timestamps a causally later event at 14:00:00.001 because its clock is two milliseconds behind, your event log says B happened before A. Your audit trail is wrong. Your debugging timeline is wrong. Any system that relies on timestamp ordering across machines is wrong, subtly, intermittently, and in ways that are extremely difficult to diagnose.

// Don't use wall clocks for ordering across services
type Event struct {
    // Wall clock — for human display only
    Timestamp time.Time `json:"timestamp"`

    // Logical sequence — for ordering
    Sequence uint64 `json:"sequence"`

    // Causal origin — which service produced this
    Origin string `json:"origin"`
}

Wall clocks are for display. Logical ordering — sequence numbers, vector clocks, or Lamport timestamps — is for determining what happened before what. If your system needs to order events from multiple sources, it needs a mechanism that doesn't depend on synchronised clocks, because the clocks are not synchronised.

HTTP is not one protocol. The specification is one thing. The implementations are dozens. Go's net/http server handles Transfer-Encoding: chunked differently from nginx's proxy handling. Some load balancers strip certain headers. Some proxies modify Content-Length. Some CDNs normalise URL encoding differently from your application server.

A request that works perfectly when sent directly to your service returns a different result when routed through a proxy, because the proxy re-encoded the URL path and your router doesn't recognise it. A webhook that validates in your test environment fails in production because the production load balancer adds a header that changes the content hash. A file upload that works from curl fails from a browser because the browser sends a slightly different multipart boundary format that your parser doesn't handle.

These aren't bugs in any individual component. They're the inevitable result of different implementations of the same specification making different choices about ambiguous or underspecified behaviours.

Character encoding is still a problem. It's tempting to believe that UTF-8 won. For new systems, it largely has. But your system doesn't exist in isolation. It receives data from user input, third-party APIs, legacy databases, CSV imports, and webhook payloads. Some of these sources send Latin-1. Some send Windows-1252. Some send UTF-8 with a BOM. Some send what they claim is UTF-8 but contains invalid byte sequences.

If your system assumes all input is valid UTF-8, it will — depending on the language and parser — silently corrupt data, replace characters with the Unicode replacement character, or crash. The fix is validating encoding at the boundary and either rejecting or transcoding anything that doesn't match your canonical format.

Contracts, Not Assumptions

The countermeasure to heterogeneity is making the contract between systems explicit rather than assumed.

Define your wire format precisely. Don't rely on "we both speak JSON" as a contract. Specify which fields exist, what types they have, what ranges are valid, how null is represented, and how dates are formatted. A schema — whether that's JSON Schema, protobuf definitions, or a documented API specification — turns implicit assumptions into explicit agreements.

// Explicit contract: dates are RFC 3339, IDs are strings, amounts are strings with two decimal places
type BookingResponse struct {
    ID        string `json:"id"`         // String, not int64 — avoids precision issues
    TenantID  string `json:"tenant_id"`
    Date      string `json:"date"`       // RFC 3339: "2026-03-15T14:00:00Z"
    Amount    string `json:"amount"`     // "149.99" — string avoids float representation issues
    Status    string `json:"status"`     // Enum: "confirmed", "cancelled", "pending"
    CreatedAt string `json:"created_at"` // RFC 3339
}

Amounts as strings, IDs as strings, dates as RFC 3339 strings. Not because Go can't handle these as native types internally, but because the wire format needs to be unambiguous across every possible consumer — Go, JavaScript, Python, a mobile client, a third-party integration. The internal representation is your concern. The wire format is a contract with every system that will ever parse it.

Version your APIs. The contract will change. When it does, the change needs to be visible and opt-in. A client built against v1 of your API should continue to work when v2 ships. This doesn't require sophisticated versioning infrastructure — a path prefix (/v1/bookings) or a header (Accept: application/vnd.bookings.v2+json) is enough. The mechanism matters less than the commitment: existing clients don't break when the contract evolves.

Parse defensively. When you receive data from another system, validate it before you use it. Don't trust that the date string is valid RFC 3339. Don't trust that the amount string is a valid decimal. Don't trust that the enum value is one you recognise. Every field that crosses a network boundary should be validated, and invalid input should produce a clear error, not a corrupted record.

func parseBookingRequest(body io.Reader) (*BookingRequest, error) {
    var raw struct {
        TenantID string `json:"tenant_id"`
        Date     string `json:"date"`
        Amount   string `json:"amount"`
    }

    if err := json.NewDecoder(body).Decode(&raw); err != nil {
        return nil, fmt.Errorf("invalid JSON: %w", err)
    }

    if raw.TenantID == "" {
        return nil, fmt.Errorf("tenant_id is required")
    }

    date, err := time.Parse(time.RFC3339, raw.Date)
    if err != nil {
        return nil, fmt.Errorf("invalid date format, expected RFC 3339: %w", err)
    }

    amount, err := decimal.NewFromString(raw.Amount)
    if err != nil {
        return nil, fmt.Errorf("invalid amount: %w", err)
    }

    return &BookingRequest{
        TenantID: raw.TenantID,
        Date:     date,
        Amount:   amount,
    }, nil
}

This is tedious. It's also the only thing that protects you from the silent corruption that heterogeneous systems produce. The parser trusts nothing and validates everything. Invalid input fails loudly at the boundary instead of silently propagating through your system.

Test Across the Boundary

Unit tests run in a homogeneous environment — one language, one runtime, one operating system. They can't catch the failures that heterogeneity produces because heterogeneity requires two different systems to interact.

Integration tests that exercise the actual wire format, through the actual network stack, between the actual services, catch what unit tests can't. A test that serialises a booking in Go, sends it over HTTP, and deserialises it in the JavaScript frontend will catch the integer precision problem. A test that runs the same request through the production proxy configuration will catch the URL encoding mismatch.

These tests are slower and more expensive than unit tests. They are also the only tests that verify the thing that actually breaks in production: the boundary between systems that don't share a runtime.

For our booking system, this means testing the API responses against every client that consumes them — the admin dashboard, the mobile app, the third-party integrations. Not just "does the endpoint return 200" but "does the response parse correctly in each consumer." The wire format is the contract. The test verifies the contract, not just the server's half of it.

The Principle

The network is not homogeneous. Your services run on different runtimes, parse data with different libraries, keep time with different clocks, and interpret specifications with different assumptions.

Make the contract explicit. Use string representations for values that lose precision across type systems. Use logical ordering instead of wall clocks for causality. Define your wire format as a schema, not as an assumption. Version it so changes don't break existing consumers. Parse defensively at every boundary. And test across the boundary, because that's where homogeneity ends and reality begins.

Everything looks the same until it doesn't. Design for the moment it doesn't.

Chapter 9: The Meta-Fallacy

There is a ninth fallacy, and it's the one that makes all the others dangerous:

Someone else has solved these problems for me.

This is the belief that the cloud provider, the service mesh, the managed database, the container orchestrator, or the platform team has absorbed the eight fallacies into their infrastructure so that you don't have to think about them. It is the most expensive belief in modern software engineering.

The Abstraction Contract

Abstractions are not solutions. They are contracts — agreements that say "you handle the part above this line, and I'll handle the part below it." The contract is genuine. The cloud provider really does manage the physical network, replace failing hardware, distribute traffic across availability zones, and handle TLS termination. The managed database really does handle replication, backups, and failover. These are real responsibilities, competently handled, that you don't have to do yourself.

But the contract has a clause that most people don't read: the abstraction handles the mechanism, not the consequences.

AWS will restart your instance when the underlying hardware fails. It will not make your application handle the restart gracefully. A managed database will failover to a replica when the primary goes down. It will not make your application handle the connection reset, the few seconds of unavailability, or the potential read-after-write inconsistency. A service mesh will retry failed requests. It will not make those requests idempotent.

The mechanism is handled. The eight fallacies are still yours.

What the Platform Cannot Do

Walk through each fallacy one more time, through the lens of what the platform provides and what it doesn't.

The platform gives you redundant network paths. The network is still not reliable. Your application still needs idempotency, retries, circuit breakers, and the decision between fail-fast and queue-for-later. The platform reduces the frequency of failures. It does not eliminate them, and it does not handle them on your behalf.

The platform gives you low-latency interconnects between services in the same region. Latency is still not zero. Your architecture still needs latency budgets, parallelisation, and the discipline to keep non-essential work off the critical path. The platform gives you fast networks. It does not give you fast systems — that's your job.

The platform gives you high-bandwidth links. Bandwidth is still not infinite. Your API responses are still too large, your replication is still unbudgeted, and your telemetry is still generating traffic you haven't measured. The platform gives you big pipes. It does not stop you from filling them with data nobody needs.

The platform gives you firewalls, security groups, and encryption options. The network is still not secure. Your services still need mTLS, your data still needs encryption at rest, and your request handling still needs authentication at every boundary. The platform gives you tools. It does not give you a security architecture.

The platform gives you auto-scaling, rolling deployments, and self-healing infrastructure. Topology still changes. Your application still needs service discovery, graceful shutdown, health checks that mean something, and the ability to handle connections to instances that no longer exist. The platform manages the topology. It does not manage your application's relationship to the topology.

The platform gives you managed services from various providers. There is still not one administrator. Your system still spans organisational boundaries. Vendor SLAs are still financial instruments, not engineering guarantees. Your critical path still depends on decisions made by people who don't know you exist.

The platform gives you internal networking that feels free. Transport still has a cost. Egress is still priced per gigabyte. Serialisation still consumes CPU. Every unnecessary service boundary still carries the full weight of failure handling, monitoring, and operational complexity. The platform makes transport easy. It does not make it free.

The platform gives you standardised protocols and managed API gateways. The network is still not homogeneous. Your services still parse numbers differently, keep time with different clocks, and interpret specifications with different assumptions. The platform gives you uniform infrastructure. It does not give you uniform semantics.

The Simplicity Argument

If the platform cannot solve these problems for you, and each problem requires engineering effort at every network boundary in your system, then the most effective architectural strategy is also the simplest: have fewer network boundaries.

This is not a rejection of distributed systems. Some systems must be distributed — the data is in multiple places, the users are in multiple regions, the scale exceeds what a single machine can handle. These are legitimate reasons to distribute, and when you distribute for legitimate reasons, the eight fallacies are the cost of doing business. You pay that cost with the patterns in this book: idempotency, retries, circuit breakers, latency budgets, payload discipline, encryption, service discovery, explicit contracts.

But most systems are distributed beyond what their requirements demand. They're distributed because microservices are the default. Because the cloud makes it easy to create new services. Because "scalability" is invoked as a requirement before anyone has measured the actual load. Because splitting things up feels like good engineering, and the costs are invisible until they're not.

Every unnecessary service boundary is a place where all eight fallacies apply and all eight countermeasures are required. Eliminating that boundary eliminates eight categories of problems. No retry logic needed for a function call. No latency budget needed for an in-process operation. No encryption needed for data that never leaves the machine. No service discovery needed for a package in the same binary.

Simplicity is not the absence of capability. It is the absence of unnecessary cost.

Deutsch Was Right

Peter Deutsch wrote the fallacies at Sun Microsystems in 1994. The internet was young. Cloud computing didn't exist. Kubernetes was twenty years away. The systems he was building would be unrecognisable to a modern engineer.

The fallacies have not aged a day.

They haven't aged because they don't describe technology. They describe the properties of networks — properties that emerge from physics, from organisational reality, and from the fundamental challenge of getting independent systems to work together. No amount of abstraction changes the speed of light. No platform eliminates the possibility of failure. No vendor controls every administrator in the path. No serialisation format bridges every type system.

The technology on top of the network will keep changing. The network underneath will keep being unreliable, slow, finite, insecure, mutable, fragmented, costly, and heterogeneous. Deutsch told us so thirty years ago. The only question is whether you design for the network you have or the network you wish you had.

Build for the one you have.