Development

Software Architecture: A Practical Guide to System Design

Learn how software architecture shapes system structure, components, scalability, reliability, maintainability, and architectural design decisions.

Software Architecture: A Practical Guide to System Design

You’re building a small web application. It has a frontend, an API, and a database. A few hundred people use it, deployments are straightforward, and when something breaks, one developer can usually trace the problem from beginning to end.

Then the application grows.

Thousands of people are using it. Background jobs are processing data. Other systems need access through APIs. Different teams are changing different parts of the codebase. Deployments happen several times a day, and a failure in one area can suddenly affect parts of the system that seem completely unrelated.

The individual pieces of code might still be perfectly reasonable. The difficult part is how those pieces fit together.

This is the problem software architecture deals with.

Software architecture defines the important structure of a software system: what its major parts are, what each part is responsible for, how they communicate, where important boundaries exist, and which technical decisions will be difficult or expensive to change later.

Good architecture doesn’t mean making a system complicated. In many cases, good architecture does exactly the opposite. It gives the system enough structure to solve the problem without introducing complexity that the problem doesn’t require.

What Is Software Architecture?

Software architecture is the high-level structure of a software system and the significant design decisions that shape how that system works.

Consider a fairly ordinary application:

┌─────────────┐
│ Web Client  │
└──────┬──────┘
       │ HTTPS

┌─────────────┐
│ Application │
│     API     │
└──────┬──────┘


┌─────────────┐
│  Database   │
└─────────────┘

Even this simple diagram contains architectural decisions.

The client doesn’t access the database directly. Requests pass through an application API. The API contains certain responsibilities, while persistent data belongs somewhere else. Communication happens across defined boundaries.

As the application becomes more complicated, those decisions multiply.

Should background work happen inside the application or through a queue? Should two areas share a database? Should a new capability become another module or an independent service? What happens if a dependency becomes unavailable? Which parts need to scale independently?

These aren’t usually decisions about individual functions or classes. They affect the structure and behaviour of the system as a whole.

That’s an important distinction because software architecture is sometimes treated as simply drawing boxes and arrows before development begins.

The boxes and arrows aren’t the architecture. They describe it.

The architecture is the set of structural decisions behind them.

Some of those decisions are relatively easy to change. Others become deeply embedded in the system. Choosing a logging library might be reversible with some work. Splitting a large application into dozens of independently deployed services can affect deployment, testing, networking, monitoring, data ownership, team structure and operational costs for years.

Architecture is therefore concerned particularly with significant decisions: decisions that influence important qualities of the system or become expensive to reverse.

To understand where those decisions begin, it helps to look at the most basic architectural concept: system structure.

Understanding System Structure

System structure describes how software is divided into parts and how those parts relate to one another.

Take an online store. At a very high level, it might need to handle:

  • customer accounts
  • products
  • shopping carts
  • orders
  • payments
  • shipping

You could put all of that logic into one large area of the codebase. Technically, the application might work.

The problem appears when everything starts depending on everything else.

Order code modifies customer data directly. Payment logic knows how inventory tables are structured. Shipping code reaches into the internals of the order system. A change to one feature unexpectedly breaks another.

A more deliberate structure establishes boundaries.

┌────────────────────────────────────────────┐
│                Online Store                │
│                                            │
│  ┌──────────┐  ┌──────────┐  ┌─────────┐  │
│  │ Accounts │  │ Products │  │  Orders │  │
│  └──────────┘  └──────────┘  └─────────┘  │
│                                            │
│  ┌──────────┐  ┌──────────┐               │
│  │ Payments │  │ Shipping │               │
│  └──────────┘  └──────────┘               │
└────────────────────────────────────────────┘

Each area has a purpose.

The order area should be responsible for orders. The payment area should be responsible for processing payments. The shipping area should be responsible for fulfilment and delivery concerns.

Exactly where those boundaries belong depends on the system, but the underlying idea is consistent: related responsibilities should be grouped together, while unrelated responsibilities should be separated.

This makes the structure easier to reason about.

If payment processing changes, developers have a clearer idea of where that change belongs. If the shipping provider needs to be replaced, the effect can potentially remain inside the shipping boundary rather than spreading throughout the application.

Boundaries don’t necessarily mean separate applications.

Two functional areas might be modules inside the same deployed application. They might be separate libraries. In a distributed system, they might eventually become separate services.

The physical implementation can change while the architectural principle remains the same: the system should have understandable responsibilities and explicit relationships between them.

Those individual areas form the building blocks of the architecture. We can think of them as components.

Components in Software Architecture

An architectural component is a meaningful part of a system with a defined responsibility and boundary.

Depending on the level at which you’re looking at the system, a component could be a module, service, database, message broker, external integration, user interface, or another independently meaningful part of the architecture.

For example:

                    ┌──────────────┐
                    │   Web App    │
                    └──────┬───────┘


                    ┌──────────────┐
                    │  Order API   │
                    └──────┬───────┘

              ┌────────────┴────────────┐
              ▼                         ▼
       ┌─────────────┐           ┌─────────────┐
       │   Payment   │           │  Inventory  │
       │  Component  │           │  Component  │
       └─────────────┘           └─────────────┘

The important part isn’t that the system contains four boxes. It’s that each box represents a responsibility.

The web application handles interaction with the user. The order component manages orders. Payment functionality handles payment concerns. Inventory handles stock.

This relates to cohesion.

A highly cohesive component contains things that naturally belong together. Payment authorisation, payment status and refund processing, for example, have a clear relationship.

Putting product search, password resets and payment authorisation into one “utilities” component would have much weaker cohesion. Those capabilities have little reason to change together.

Clear components also support separation of concerns. A component shouldn’t need detailed knowledge of every other part of the system to perform its job.

That doesn’t mean components never depend on one another.

An order component may genuinely need the payment component. A shipping component may genuinely need information about an order. The goal isn’t to eliminate relationships; it’s to make those relationships deliberate and understandable.

This is where architecture becomes more interesting.

Components don’t do much in isolation. Real systems work because components exchange requests, data and events with one another.

How they do that has a major effect on the architecture.

How Components Interact

Once a system has been divided into components, the next question is how those components communicate.

The simplest interaction is a direct call.

┌───────────────┐      request       ┌───────────────┐
│ Order Service │ ─────────────────► │Payment Service│
│               │ ◄───────────────── │               │
└───────────────┘      response      └───────────────┘

The order service asks the payment service to perform some operation and waits for a response.

In a single application, this interaction might be a normal function or method call. Across separate services, it could happen through an HTTP API, RPC interface or another network protocol.

This is synchronous communication: the caller generally expects the other component to respond before it can continue.

Another option is asynchronous communication.

┌───────────────┐
│ Order Service │
└───────┬───────┘

        │ OrderCreated

┌────────────────┐
│ Message Broker │
└───────┬────────┘

        ├──────────────────┐
        ▼                  ▼
┌──────────────┐    ┌──────────────┐
│   Shipping   │    │ Notification │
│   Service    │    │   Service    │
└──────────────┘    └──────────────┘

Here, the order service publishes information about something that happened. Other components can process that information independently.

Neither approach is automatically better.

Synchronous calls can be simple and easy to understand. They also create a direct runtime dependency: if the payment service is unavailable, the order service needs to decide what to do.

Asynchronous communication can reduce that direct dependency and allow work to happen independently. But now the system has to deal with queues or brokers, delayed processing, retries, duplicate messages, ordering and potentially eventual consistency.

Architecture is full of choices like this.

Interactions also determine how data flows through the system. If five services all call one another to complete a request, latency and failure can propagate through the entire chain. If every component reads and writes the same database tables, their apparent separation might disappear as soon as the data model changes.

This is why coupling matters.

Coupling describes how strongly one part of the system depends on another. Tightly coupled components know a lot about each other’s implementation or require each other to operate. Loosely coupled components depend on smaller, more stable contracts.

Consider these two designs:

TIGHTER COUPLING

Component A ─────► Component B's internal data
            ─────► Component B's database tables
            ─────► Component B's implementation details


LOOSER COUPLING

Component A ─────► Defined interface ─────► Component B

Loose coupling doesn’t mean no coupling. Useful software components have dependencies.

The goal is to depend on the right things.

An order system needing a payment result is a meaningful dependency. An order system needing to know the exact table structure used internally by the payment system probably isn’t.

Good component interactions therefore depend on more than choosing an API or message broker. They depend on principles for deciding where responsibilities and dependencies should exist in the first place.

Software Architecture Design Principles

Architecture involves countless individual decisions, but several design principles appear repeatedly because they help keep systems understandable as they change.

Separation of Concerns

Separation of concerns means dividing a system so that different responsibilities are handled in appropriate places.

Authentication logic shouldn’t be scattered randomly through unrelated business code. Database access shouldn’t leak into every layer of the application. Payment-provider-specific behaviour shouldn’t become a requirement for every component that needs to know whether an order has been paid.

The clearer the concerns, the easier it becomes to understand what a part of the system is supposed to do.

Modularity

Modularity takes this further by organising related functionality into distinct units.

A modular system doesn’t necessarily mean microservices. A well-designed monolithic application can be highly modular.

┌──────────────────── Application ────────────────────┐
│                                                    │
│   ┌──────────┐   ┌──────────┐   ┌──────────────┐   │
│   │ Accounts │   │  Orders  │   │  Inventory   │   │
│   └──────────┘   └──────────┘   └──────────────┘   │
│                                                    │
└────────────────────────────────────────────────────┘

All three modules may run in the same process and deploy together while still maintaining useful internal boundaries.

Loose Coupling

Loose coupling reduces unnecessary dependencies between components.

Suppose an application sends email through a particular provider. If business logic directly uses that provider’s SDK everywhere, replacing the provider may require changes throughout the codebase.

Instead, the application can depend on a narrower interface:

Business Logic


Notification Interface


Email Provider

The dependency still exists, but knowledge of the implementation has been contained.

High Cohesion

High cohesion means keeping strongly related behaviour together.

If all order-related rules live around the order component, developers can understand and modify order behaviour without searching through unrelated areas of the system.

High cohesion and loose coupling often reinforce each other: keep related responsibilities together and minimise unnecessary knowledge between those groups.

Encapsulation

Encapsulation means hiding internal implementation details behind a controlled boundary.

A component should expose what other components need to use without exposing everything about how it works.

A database provides a useful analogy. A caller might ask an account component for a customer record. It shouldn’t necessarily need to know which tables were queried, whether the information came from a cache, or how the component internally represents that customer.

That internal implementation can then change without forcing every caller to change with it.

Simplicity

Simplicity is one of the easiest architectural principles to agree with and one of the easiest to ignore.

A system can be modular without having twenty services. It can be scalable without introducing a distributed event platform on its first day. It can have clear boundaries without putting a network connection between every functional area.

Every architectural mechanism has a cost.

A queue needs operating and monitoring. A service needs deploying. A cache introduces invalidation problems. Another database introduces another place where data can become inconsistent. Another abstraction gives developers another concept to understand.

Complexity can be justified, but it should solve a real problem.

A useful way to think about these principles together is:

              ┌─────────────────────┐
              │ Clear Responsibilities│
              └──────────┬──────────┘

              ┌──────────▼──────────┐
              │    High Cohesion    │
              └──────────┬──────────┘

              ┌──────────▼──────────┐
              │ Explicit Boundaries │
              └──────────┬──────────┘

              ┌──────────▼──────────┐
              │    Loose Coupling   │
              └──────────┬──────────┘

              ┌──────────▼──────────┐
              │ Easier to Change    │
              └─────────────────────┘

That final point is important.

Software architecture isn’t valuable because a system looks tidy on a diagram. It’s valuable because software changes.

Features are added. Requirements shift. Traffic grows. Dependencies are replaced. Teams expand. Decisions that were perfectly sensible when a system had 500 users may become serious limitations when it has five million.

Architecture therefore isn’t something decided once at the beginning of a project and then preserved forever.

It grows with the system.

How Software Architecture Changes

Software architecture is rarely finished.

The architecture that makes sense when an application has three developers and a few thousand users may not make sense when it has several teams, millions of users, large amounts of data, and dozens of external integrations.

Consider a small application that begins like this:

┌─────────────┐
│   Web App   │
└──────┬──────┘


┌─────────────┐
│ Application │
└──────┬──────┘


┌─────────────┐
│  Database   │
└─────────────┘

There is nothing inherently wrong with this architecture.

In fact, for many applications it is an excellent place to start. There are relatively few moving parts, deployments are straightforward, local development is manageable, and developers can follow a request through the entire system.

Then requirements change.

The application starts sending thousands of emails, so email delivery moves into background jobs. Search becomes too expensive for the main database, so a dedicated search system is introduced. Traffic increases, requiring multiple application instances behind a load balancer. An external payment provider is added. Reporting workloads begin competing with normal application queries.

The architecture gradually becomes something more like this:

                         ┌─────────────┐
                         │   Clients   │
                         └──────┬──────┘


                         ┌─────────────┐
                         │Load Balancer│
                         └──────┬──────┘

                   ┌────────────┴────────────┐
                   ▼                         ▼
            ┌─────────────┐           ┌─────────────┐
            │ App Instance│           │ App Instance│
            └──────┬──────┘           └──────┬──────┘
                   └────────────┬─────────────┘

             ┌──────────────────┼──────────────────┐
             ▼                  ▼                  ▼
      ┌────────────┐     ┌────────────┐     ┌────────────┐
      │  Database  │     │   Queue    │     │   Search   │
      └────────────┘     └──────┬─────┘     └────────────┘


                         ┌────────────┐
                         │Background  │
                         │  Workers   │
                         └────────────┘

No single decision necessarily transformed the architecture. It grew as new problems appeared.

Growth in users and data is one reason this happens, but scale isn’t the only one.

A business may enter a new market with different regulatory requirements. A third-party system may need integration. One product may split into several products. A team of five developers may become five teams, each needing to release changes without constantly coordinating with everyone else.

Organizational changes can therefore influence architecture just as much as technical changes.

The important point is that architectural change should generally be driven by actual constraints rather than predictions about every problem the system might encounter one day.

Designing for reasonable future change is useful. Designing an elaborate distributed system because the application might someday have millions of users can leave a team paying the cost of complexity long before it receives any benefit from it.

Architecture can instead change incrementally.

A module can be separated when its responsibilities become unclear. A cache can be introduced when repeated computation becomes a measurable bottleneck. Background processing can be added when work no longer belongs in the request path. A service can be extracted when independent deployment or scaling provides enough value to justify it.

As systems grow, they often begin to resemble common architectural patterns.

These patterns aren’t recipes that every application should follow. They’re recurring ways of structuring systems that solve particular kinds of problems.

One of the most familiar is layered architecture.

Layered Architecture

Layered architecture divides an application according to different kinds of responsibility.

A common version has three broad layers:

┌────────────────────────────┐
│     Presentation Layer     │
│    UI / Controllers / API  │
└─────────────┬──────────────┘


┌────────────────────────────┐
│   Business / Domain Layer  │
│ Rules / Workflows / Logic  │
└─────────────┬──────────────┘


┌────────────────────────────┐
│         Data Layer         │
│ Repositories / Persistence │
└─────────────┬──────────────┘


        ┌──────────┐
        │ Database │
        └──────────┘

The presentation layer deals with interaction coming into the application. In a web application, that might include HTTP endpoints, controllers, request validation, or user-interface concerns.

The business or domain layer contains the rules that make the application useful. It might decide whether an order can be cancelled, calculate a price, determine whether a customer is eligible for something, or coordinate a business workflow.

The data layer handles persistence and retrieval.

The exact names and number of layers vary between systems. The important idea is the direction of responsibilities.

Suppose a user submits an order.

The presentation layer receives the request. The business layer applies the rules required to create the order. The data layer persists it.

HTTP Request


Controller


Order Logic


Repository


Database

This separation can make applications relatively easy to understand. Developers know roughly where different kinds of code belong, and concerns such as database access don’t need to be mixed directly into user-interface code.

Layered architectures are especially common in business applications where requests move through reasonably predictable stages.

They also have limitations.

Layers can become artificial boundaries that every operation must pass through, even when those boundaries provide little value. A supposedly independent business layer can become tightly coupled to the data layer beneath it. Large applications may accumulate enormous shared layers where unrelated features become tangled together.

Another common problem is that the diagram looks more modular than the application actually is.

        ┌──────────────────┐
        │ Presentation     │
        └────────┬─────────┘

        ┌──────────────────┐
        │ Business Logic   │
        │                  │
        │ Orders           │
        │ Payments         │
        │ Accounts         │
        │ Inventory        │
        │ Shipping         │
        │ Reporting        │
        └────────┬─────────┘

        ┌──────────────────┐
        │ Shared Database  │
        └──────────────────┘

Everything may technically occupy the correct layer while still being heavily interconnected.

For many systems, this is manageable. A well-structured application can remain a single deployment for a very long time, and sometimes for its entire lifetime.

Problems begin when parts of the system need substantially different operational characteristics.

Perhaps the image-processing workload needs far more computing power than the rest of the application. Perhaps one team needs to release its part independently. Perhaps a failure in reporting shouldn’t be capable of affecting checkout.

Those requirements can create pressure to move some boundaries out of the application itself and make components independently deployable.

That leads to microservices.

Microservices Architecture

Microservices architecture structures a system as a collection of independently deployable services, usually organised around distinct business capabilities.

An ecommerce system might look something like this:

                         ┌────────────┐
                         │   Client   │
                         └─────┬──────┘


                         ┌────────────┐
                         │ API Gateway│
                         └─────┬──────┘

             ┌─────────────────┼─────────────────┐
             │                 │                 │
             ▼                 ▼                 ▼
      ┌────────────┐    ┌────────────┐    ┌────────────┐
      │  Accounts  │    │   Orders   │    │  Products  │
      │  Service   │    │  Service   │    │  Service   │
      └─────┬──────┘    └─────┬──────┘    └─────┬──────┘
            │                 │                 │
            ▼                 ▼                 ▼
       ┌────────┐        ┌────────┐        ┌────────┐
       │Account │        │ Order  │        │Product │
       │  Data  │        │  Data  │        │  Data  │
       └────────┘        └────────┘        └────────┘

Instead of one application containing every capability, accounts, orders, products and other areas can operate as separate services.

The word independent is important here.

Simply splitting a codebase into several applications doesn’t automatically produce useful microservices. If every deployment requires all services to be released together, every service reads and writes the same tables, and a change in one service constantly requires changes in five others, the system remains tightly coupled despite having more processes.

Useful service boundaries give teams some degree of independence.

An order service might own the rules and data associated with orders. Other services interact with it through defined APIs or messages rather than directly modifying its internal data.

That can provide several advantages.

A service can potentially be deployed without redeploying the entire system. Different services can scale differently. Teams can own specific capabilities. Failures can sometimes be isolated more effectively.

Suppose image processing requires large amounts of CPU while account management doesn’t. In a single application, scaling may mean adding copies of the entire application.

With separate services, the architecture can potentially scale the expensive workload independently:

┌────────────────┐
│ Account Service│  x2
└────────────────┘

┌────────────────┐
│  Image Service │  x10
└────────────────┘

That flexibility is useful, but it isn’t free.

A method call inside one process is extremely different from a call across a network.

Networks introduce latency. Requests time out. Services become temporarily unavailable. Deployments can leave different versions running simultaneously. Authentication between services becomes necessary. Logs are distributed across multiple machines. A single user request may need tracing across several systems.

Data becomes more difficult too.

In a monolithic application with one relational database, a transaction can often update several related records atomically.

With independently owned services and databases, the same operation might cross service boundaries.

Consider placing an order:

Order

  ├──► Reserve inventory

  ├──► Take payment

  └──► Arrange shipping

What happens if inventory succeeds but payment fails?

What if payment succeeds but the shipping service is temporarily unavailable?

What if a network timeout occurs after the payment provider charged the customer but before the order service received the response?

These are distributed-system problems that a simpler architecture may not need to solve.

Microservices are therefore most useful when the benefits of independent ownership, deployment, scaling or fault isolation outweigh the additional operational complexity.

A small team building a straightforward application may gain very little from splitting it into twenty services. The same design could actually make development slower because every feature now crosses network, deployment and observability boundaries.

A larger organization with distinct teams and independently changing business capabilities may have much stronger reasons to accept those costs.

This is a recurring theme in architecture: a pattern isn’t good because it is sophisticated. It is good when its advantages match the constraints of the system.

Microservices also create another architectural question.

If independent services need to communicate, do they always need to call each other directly?

Not necessarily.

Sometimes a service doesn’t need another service to perform something immediately. It only needs to announce that something happened.

That is where event-driven architecture becomes useful.

Event-Driven Architecture

Event-driven architecture organizes communication around events: records that something meaningful has happened in the system.

An event might say:

OrderPlaced
PaymentCompleted
AccountCreated
ShipmentDispatched
PasswordChanged

Instead of one component directly instructing several other components what to do, it can publish an event.

Other components subscribe to events that matter to them.

                      OrderPlaced


                  ┌────────────────┐
                  │ Event Broker / │
                  │ Event Stream   │
                  └───────┬────────┘

          ┌───────────────┼────────────────┐
          │               │                │
          ▼               ▼                ▼
   ┌────────────┐   ┌────────────┐   ┌────────────┐
   │ Inventory  │   │   Email    │   │ Analytics  │
   │  Consumer  │   │  Consumer  │   │  Consumer  │
   └────────────┘   └────────────┘   └────────────┘

The order component doesn’t necessarily need to know that analytics wants the event or that an email will be sent because of it.

It publishes the fact that an order was placed.

Consumers decide what that fact means to them.

This can reduce coupling between components. If a new analytics system later needs information about orders, it may be able to subscribe to OrderPlaced without requiring the order service to make another direct API call.

Compare that with direct communication:

DIRECT

Order Service ──► Inventory
              ├─► Email
              ├─► Analytics
              └─► Loyalty Program

As more integrations appear, the producer becomes aware of more systems.

With events:

EVENT-DRIVEN

Order Service ──► OrderPlaced ──► Broker

                                  ├──► Inventory
                                  ├──► Email
                                  ├──► Analytics
                                  └──► Loyalty Program

The producer can remain relatively stable while consumers change.

Event-driven systems also support asynchronous processing. The order service may not need to wait for analytics or email delivery before telling the customer that the order was accepted.

This can improve responsiveness and allow components to continue operating at different speeds.

It also introduces a different kind of complexity.

An event may not be processed immediately. A consumer may fail and retry later. The same event may need to be handled more than once. Events may arrive in an unexpected order. Developers need a way to trace what happened across asynchronous workflows.

Most importantly, different parts of the system may temporarily disagree.

An order is created at 10:00:00.

The order service records it immediately. The search index receives the event at 10:00:01. The analytics system processes it at 10:00:03.

For those few seconds, the systems don’t all have the same view of reality.

This is eventual consistency.

10:00:00     Order stored


10:00:01     Search updated


10:00:03     Analytics updated


Eventually all consumers reflect the event

Eventual consistency can be completely acceptable for some information. An analytics dashboard being a few seconds behind may not matter.

It can be unacceptable for other information. A system deciding whether the same limited inventory item can be sold twice may have much stricter consistency requirements.

Event-driven architecture therefore isn’t a replacement for direct communication. Many real systems use both.

Events work particularly well when multiple independent consumers need to react to something that happened and immediate processing isn’t required.

Direct requests are often clearer when one component needs an immediate answer from another.

That direct request-response relationship is at the heart of another extremely common architectural style: client-server architecture.

Client-Server Architecture

Client-server architecture divides responsibilities between a client, which requests something, and a server, which provides a service or resource.

The web is built heavily around this model.

When you visit a website, your browser acts as a client. It sends a request to a server, which processes that request and returns a response.

┌────────────┐                       ┌────────────┐
│   Client   │ ────── Request ─────► │   Server   │
│            │ ◄───── Response ───── │            │
└────────────┘                       └────────────┘

A mobile application calling an API follows the same basic pattern.

For example:

GET /products/42

The client requests information about product 42. The server determines how to fulfil that request, perhaps by reading from a database or another service, and returns the result.

The responsibilities are deliberately different.

The client may handle presentation, local interaction and some application state. The server provides centralized capabilities such as authentication, business logic, shared data or access to other systems.

One server can also support many different clients.

┌────────────┐
│  Web App   │───────┐
└────────────┘       │

┌────────────┐       │       ┌──────────────┐
│ Mobile App │───────┼──────►│     API      │
└────────────┘       │       │    Server    │
                     │       └──────┬───────┘
┌────────────┐       │              │
│ Admin Tool │───────┘              ▼
└────────────┘                ┌──────────────┐
                              │   Database   │
                              └──────────────┘

This centralization can be useful.

Business rules don’t need to be fully duplicated in every client. Data can be managed centrally. Security controls can be enforced server-side. Clients can be updated independently from parts of the backend.

The model also introduces dependencies.

If the server becomes unavailable, clients may lose access to the capabilities it provides. If every request depends on one overloaded server, that server can become a bottleneck.

Network conditions matter as well. A function call inside an application may complete in microseconds or milliseconds. A client-server interaction has network latency and can fail for reasons unrelated to the application logic itself.

Client-server architecture is therefore often only the beginning of the system diagram.

The “server” may itself be a collection of load balancers, application instances, caches, databases, queues and services:

 Client


┌──────────────┐
│Load Balancer │
└──────┬───────┘

   ┌───┴────────────┐
   ▼                ▼
┌───────┐        ┌───────┐
│Server │        │Server │
│   A   │        │   B   │
└───┬───┘        └───┬───┘
    │                │
    └───────┬────────┘

       ┌────▼─────┐
       │ Database │
       └──────────┘

That change usually happens for a reason: the original server can only handle a finite amount of work.

As traffic increases, architecture determines whether additional capacity can be added easily or whether one component becomes a hard limit.

This brings us to one of the most important quality attributes in system design: scalability.

Scalability in Software Architecture

Scalability is the ability of a system to handle increased demand without becoming unacceptably slow, unreliable, or expensive.

That demand can take several forms.

More users might be making requests. More data might need to be stored. Background jobs might increase from thousands to millions. An API that once handled ten requests per second might eventually need to handle ten thousand.

The first response to increased demand is often simple: give the existing system more resources.

This is vertical scaling.

BEFORE

┌────────────────┐
│     Server     │
│                │
│   4 CPU cores  │
│    8 GB RAM    │
└────────────────┘




AFTER

┌────────────────┐
│     Server     │
│                │
│  32 CPU cores  │
│   128 GB RAM   │
└────────────────┘

The architecture hasn’t fundamentally changed. The machine has simply become more powerful.

Vertical scaling is useful because it is straightforward. If a database needs more memory, adding memory may be considerably easier than redesigning the entire data layer.

But individual machines have limits.

At some point, larger hardware becomes expensive or simply isn’t available. A single machine can also remain a single point of failure regardless of how powerful it is.

The alternative is horizontal scaling: adding more instances rather than making one instance larger.

                   ┌──────────────┐
Requests ─────────►│Load Balancer │
                   └──────┬───────┘

             ┌────────────┼────────────┐
             ▼            ▼            ▼
        ┌────────┐   ┌────────┐   ┌────────┐
        │ App 1  │   │ App 2  │   │ App 3  │
        └────────┘   └────────┘   └────────┘

Now incoming work can be distributed across several application instances.

If traffic increases again, another instance can potentially be added.

This works particularly well when application instances are stateless.

A stateless component doesn’t depend on important session information existing only in the memory of one particular instance.

A user logs in through Server A. Their next request happens to reach Server B.

If Server B has no way to recognize the user because the session exists only inside Server A’s memory, distributing requests becomes more difficult.

PROBLEM

User ──► Server A
         session stored here

User ──► Server B
         "Who are you?"

Systems can solve this in several ways, such as storing shared session state externally or using tokens that allow any application instance to process the request.

The important architectural point is that state affects scalability.

Adding ten application servers also doesn’t mean the entire system can suddenly handle ten times as much traffic.

The bottleneck may simply move.

             ┌──────────────┐
             │Load Balancer │
             └──────┬───────┘

        ┌───────────┼───────────┐
        ▼           ▼           ▼
     ┌─────┐     ┌─────┐     ┌─────┐
     │ App │     │ App │     │ App │
     └──┬──┘     └──┬──┘     └──┬──┘
        │           │           │
        └───────────┼───────────┘

             ┌─────────────┐
             │  Database   │
             │ BOTTLENECK  │
             └─────────────┘

Perhaps every application instance depends on the same database. As application capacity increases, database connections and queries increase with it.

Eventually the database, rather than the application, becomes the constraint.

The same thing can happen with caches, queues, external APIs, storage systems, network bandwidth, or individual services.

Scalability is therefore a property of the architecture as a whole, not simply the number of servers running it.

A scalable design identifies where demand is likely to grow, where state exists, which resources are shared, and which components can be replicated or divided when necessary.

But scalability shouldn’t be pursued without considering what it does to the rest of the system.

Sharding a database may increase data capacity while making queries and transactions more difficult. Introducing a cache may reduce database load while creating invalidation problems. Splitting workloads into independent services may allow targeted scaling while increasing operational complexity.

The system still has to be understood and changed by people.

As architecture grows, that makes another quality increasingly important: maintainability.

Maintainability

Maintainability describes how easily a software system can be understood, modified, tested, and extended.

This can sound less urgent than performance or scalability because poor maintainability rarely causes an immediate outage.

Its cost appears gradually.

A feature that should take a day takes a week because nobody knows which parts of the system it affects. A seemingly small change breaks unrelated functionality. Developers avoid modifying one component because its behaviour is poorly understood. Tests require half the system to be running before anything can be verified.

Eventually, changing the software becomes the difficult part of building it.

Architecture has a major influence on whether that happens.

Consider two systems implementing the same capabilities:

SYSTEM A

Orders ──────► Payments
   │          ↗   │
   ├──────────────┤
   ▼          ↘   ▼
Inventory ◄──── Shipping
   │             ▲
   └─────────────┘


SYSTEM B

┌────────┐
│ Orders │
└───┬────┘
    │ defined interfaces
    ├────────► Payments
    ├────────► Inventory
    └────────► Shipping

The first system has relationships in many directions. Understanding a change requires understanding several components and their internal assumptions.

The second isn’t dependency-free, but its relationships are clearer.

This is why the earlier principles of modularity, cohesion and coupling matter so much.

A maintainable system usually gives developers reasonable answers to questions like:

Where does this behaviour belong?

What depends on this component?

What can I change without affecting callers?

How can I test this behaviour?

Which team owns this part of the system?

Clear boundaries reduce the amount of context required to make a change safely.

Testability matters for the same reason.

If a component has a defined responsibility and interacts with other components through explicit interfaces, it can often be tested without recreating the entire production environment.

If everything depends directly on everything else, isolated testing becomes much harder.

Maintainability doesn’t require every component to be tiny.

In fact, excessive fragmentation can have the opposite effect. Replacing a straightforward module with several services means developers may now need to understand network communication, deployment configuration, service discovery, distributed tracing and failure handling just to follow one workflow.

The objective isn’t to create the maximum number of boundaries.

It’s to create useful boundaries.

A good structure reduces the number of unrelated things a developer needs to understand at once while keeping important relationships visible.

That becomes increasingly valuable as both the system and the team grow.

But a system that is easy to modify still isn’t necessarily a good system if it regularly stops working.

Long-lived systems also need to remain dependable when individual components fail.

That brings us to reliability.

Reliability

Reliability is the ability of a system to continue providing correct and useful behaviour over time, including when things go wrong.

And things will go wrong.

Servers crash. Networks time out. Disks fail. Processes run out of memory. Databases become unavailable. External APIs return errors. Deployments contain bugs.

Architecture can’t guarantee that individual components never fail.

Instead, good architecture considers what happens when they do.

Suppose an application depends on one server:

Users


┌────────────┐
│ App Server │
└─────┬──────┘


┌────────────┐
│  Database  │
└────────────┘

If that application server fails, the service becomes unavailable.

Adding redundant instances changes the failure model:

                   ┌──────────────┐
Users ────────────►│Load Balancer │
                   └──────┬───────┘

                  ┌───────┴───────┐
                  ▼               ▼
             ┌────────┐      ┌────────┐
             │ App A  │      │ App B  │
             └────────┘      └────────┘
                  X               │
                fails             │

                           requests continue

One application instance can fail while another continues processing requests.

This is redundancy: providing more than one component capable of performing an important function.

Redundancy can improve availability, but simply duplicating everything doesn’t solve every reliability problem.

If both application instances depend on the same unavailable database, neither can complete useful work.

      ┌────────┐       ┌────────┐
      │ App A  │       │ App B  │
      └───┬────┘       └───┬────┘
          │                │
          └───────┬────────┘

             ┌────────┐
             │Database│
             └────────┘
                  X

Architects therefore look for single points of failure: components whose failure can bring down an important part of the system.

Another concern is fault isolation.

A reporting feature starts executing extremely expensive queries against the same database used for checkout. The reporting workload consumes the available database resources, and customers can no longer place orders.

The reporting system didn’t directly break checkout. Shared resources allowed its failure to spread.

A more isolated design might separate those workloads or place controls around the resources each can consume.

This is sometimes described as reducing the blast radius of a failure.

Recovery matters too.

If a service crashes, can it restart automatically? If a database server fails, can another instance take over? If a message cannot be processed, is it retried? If a deployment introduces a serious bug, can the previous version be restored?

Reliable systems are designed around the assumption that failures occur.

This leads to the broader concept of resilience: the ability of a system to absorb failures and recover from them rather than turning every component failure into a system-wide failure.

But resilience mechanisms have their own costs.

Retries can cause duplicate operations. Redundant databases require replication. Failover mechanisms need testing. Replicated systems need to decide what happens when nodes disagree. Additional infrastructure creates additional things that can themselves fail.

Improving reliability, scalability and maintainability therefore tends to introduce new decisions and new complexity.

Over time, some of those decisions stop fitting the system as well as they once did.

Others were compromises from the beginning.

The accumulated cost of those compromises is commonly described as technical debt.

Technical Debt and Architecture

Technical debt is the future cost created when a technical decision makes later change more difficult.

The term is often used for messy code, missing tests or shortcuts taken to meet a deadline.

But technical debt can exist at the architectural level too.

A company launches a product quickly using one application and one shared database.

┌────────────────────────────┐
│        Application         │
│                            │
│ Accounts                   │
│ Orders                     │
│ Payments                   │
│ Inventory                  │
│ Reporting                  │
└─────────────┬──────────────┘


       ┌──────────────┐
       │Shared Database│
       └──────────────┘

At the beginning, this might be exactly the right choice.

It’s simple. Developers can move quickly. Transactions are straightforward. There is little operational overhead.

Five years later, several teams may be working in the same application. Reporting queries compete with customer traffic. Database schema changes require coordination across teams. Deployments have become risky because unrelated features are released together.

The original architecture wasn’t necessarily a mistake.

The context changed.

Technical debt appears when the current structure makes necessary changes increasingly expensive.

Some debt is intentional.

A team may know that a particular integration is temporary but choose the quickest implementation because the business needs to launch in two weeks.

That can be a rational trade-off.

Today

  ├── Quick implementation

  └── Faster launch


Later

  └── Additional work required
      to replace or restructure it

The problem isn’t taking on debt. The problem is forgetting that the debt exists or allowing its cost to grow without understanding it.

Architectural debt can also be accidental.

Boundaries that were once clear become blurred as features are added. Two components begin sharing data directly because it is convenient. Temporary integrations become permanent. Old dependencies remain because nobody is sure what still uses them.

Eventually the system contains assumptions that nobody deliberately designed.

One common warning sign is that changes repeatedly cross architectural boundaries.

Suppose the payment component was intended to be independent, but every payment change also requires modifications to orders, customer accounts, reporting and shipping.

The diagram may still show a payment component.

In practice, its boundary has weakened.

Technical debt constrains future choices because architecture has inertia.

Changing a function might take minutes. Changing the ownership of data across several production systems can take months.

That is why architectural decisions deserve more attention than easily reversible implementation details.

At the same time, trying to eliminate all technical debt can be just as harmful.

A team could spend months designing perfect abstractions for requirements that never arrive. It could build a distributed architecture to avoid theoretical scaling problems while delaying the product customers actually need.

Architecture always involves choosing what to optimize and what cost to accept.

In other words, it involves trade-offs.

Architectural Trade-Offs

There is rarely one universally best software architecture.

A design can be excellent for one system and unnecessarily complicated for another because architecture is always evaluated against requirements and constraints.

Consider simplicity versus flexibility.

A direct integration with one payment provider may be easy to build and understand:

Application ─────► Payment Provider

Introducing an abstraction makes it easier to support several providers:

                 ┌──► Provider A
Application ─► Payment Interface
                 └──► Provider B

The second design is more flexible.

It also contains an abstraction the first system doesn’t need.

If the business has no realistic requirement to support another provider, the additional flexibility may provide little value.

The same tension appears with performance versus maintainability.

Highly optimized code or specialized data structures may make a critical operation dramatically faster while making the system harder to understand and modify.

For a latency-sensitive trading system, that may be a sensible trade.

For an internal administration page used twenty times per day, it probably isn’t.

Distributed systems introduce another famous tension: consistency versus availability during certain failures.

If different nodes cannot communicate, a system may sometimes need to choose between refusing an operation until it can guarantee consistent data or continuing to serve requests while accepting that different parts of the system may temporarily disagree.

The right behaviour depends on the data.

An analytics count being temporarily stale may be acceptable.

Two customers both being told they successfully purchased the final unique item may not be.

There is also coupling versus operational complexity.

Splitting a large application into independent services can create stronger ownership boundaries and allow separate deployments.

But now the organization has more services to deploy, secure, monitor and debug.

MONOLITH

Less operational complexity


┌──────────────────┐
│   Application    │
└──────────────────┘


SERVICES

More independent boundaries


┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐
│Svc A │ │Svc B │ │Svc C │ │Svc D │
└──────┘ └──────┘ └──────┘ └──────┘
    + networking
    + deployments
    + monitoring
    + distributed failures

Neither side is automatically correct.

Finally, almost every real software project faces short-term delivery versus long-term adaptability.

A business needs software today. Architecture needs to support tomorrow.

Optimizing entirely for today can create a system that becomes painfully expensive to change. Optimizing entirely for hypothetical future requirements can produce an overengineered system that takes too long to deliver.

Good architecture operates between those extremes.

The question isn’t:

What is the best architecture?

It’s:

What architecture makes the most appropriate trade-offs for this system?

Answering that requires context outside the software itself.

You need to know what the business is actually trying to accomplish.

How Business Requirements Shape Architecture

Software architecture exists to support business requirements.

This sounds obvious, but architecture discussions can easily begin with technology instead.

Should we use microservices?

Should we use events?

Should we use Kubernetes?

Should we use this database?

Those questions are difficult to answer until the problem is understood.

Suppose two companies are building systems that process payments.

The first is a small retailer processing a few hundred transactions per day.

The second operates payment infrastructure processing thousands of transactions per second across several regions.

Both systems “process payments,” but their architectural requirements are very different.

Requirements usually begin with functional requirements: what the system must actually do.

For an online store, those might include:

Customer can browse products
Customer can place an order
Customer can make a payment
Customer can track shipping
Administrator can issue a refund

Architecture must provide a structure capable of supporting those behaviours.

But functionality is only part of the picture.

Systems also have quality requirements, sometimes called non-functional requirements.

How quickly should a request respond?

How many users must the system support?

How much downtime is acceptable?

How quickly must the system recover from failure?

How secure must particular information be?

How consistent does data need to be?

Two architectures that provide exactly the same features can behave very differently under those constraints.

Expected growth matters too.

A system expected to serve 500 internal employees has different scaling concerns from a public service expected to support tens of millions of users.

That doesn’t mean the larger system automatically needs microservices or any particular pattern. It means expected demand becomes part of the decision.

Then there are practical constraints.

Budget limits which infrastructure and services are realistic. Deadlines limit how much can be built before launch. Existing systems may need to remain in place. Regulatory requirements may determine where data can be stored or how it must be protected.

Team structure is another major constraint.

An architecture that requires specialists to operate dozens of distributed services may be a poor choice for a team of four developers with limited operational capacity.

Conversely, one enormous application may become difficult for an organization with hundreds of developers who need to release independently.

Architecture therefore sits between business needs and technical implementation:

┌──────────────────────────┐
│      Business Goals      │
└────────────┬─────────────┘

┌──────────────────────────┐
│       Requirements       │
│                          │
│ Functional               │
│ Scalability              │
│ Reliability              │
│ Security                 │
│ Budget                   │
│ Time                     │
│ Team capabilities        │
└────────────┬─────────────┘

┌──────────────────────────┐
│ Architectural Decisions  │
└────────────┬─────────────┘

┌──────────────────────────┐
│      Software System     │
└──────────────────────────┘

Technology choices come after the requirements because architecture is not an exercise in selecting interesting technology.

A queue is useful when asynchronous processing solves a real requirement.

A cache is useful when reducing repeated work or latency solves a real requirement.

Independent services are useful when their independence solves problems worth the cost of distributing the system.

Business requirements provide the context that lets architects make those judgments.

The final step is turning that context into explicit design decisions.

Making Architectural Design Decisions

Architectural design decisions should begin with a problem, not a preferred solution.

A useful decision process looks like this:

Business Need


Requirement


Architectural Constraint


Possible Options


Trade-Off Analysis


Design Decision

Consider an application where users upload videos.

Initially, the application processes each video during the HTTP request.

Client

  │ Upload

Application

  │ Process video

  │ Generate thumbnails

  │ Store output

Response

As files become larger, requests begin taking too long and sometimes time out.

The architectural problem isn’t simply “we need a queue.”

The problem is that expensive processing is happening inside a request that needs to return quickly.

That gives us a requirement:

Accept an upload quickly without requiring the client to wait for video processing to finish.

There are then several possible solutions.

Processing could be optimized enough to remain synchronous. The client could upload directly to storage. Processing could move into background workers. A managed media-processing service could handle the workload.

Each option has different consequences for cost, complexity, scalability, reliability and development time.

Suppose background processing is selected:

Client

  │ Upload

Application ─────► Storage

  │ Processing job

Queue


Worker

  ├──► Process video

  └──► Store result

The new architecture solves the request-time problem and allows workers to scale independently.

It also introduces a queue, asynchronous state, retries, job failures and additional monitoring.

Those costs are part of the decision.

A good architectural process makes them explicit.

Identify the Problem

Start with what isn’t working or what new capability is required.

Avoid jumping immediately from a symptom to a technology.

“Database queries are becoming slow as the dataset grows” is a problem.

“We need Redis” is already a proposed solution.

That distinction keeps the solution space open.

Define Requirements and Constraints

Determine what the architecture actually needs to achieve.

For example:

Requirement:
95% of requests should complete within 300 ms

Constraint:
Existing relational database must remain in use

Constraint:
Team cannot operate another database platform

Expected growth:
Traffic may triple over the next 12 months

Concrete constraints make architectural discussions much more useful.

Without them, almost any architecture can be defended.

Identify Viable Options

There is usually more than one way to solve an architectural problem.

A read-heavy application experiencing database pressure might consider query optimization, indexes, caching, read replicas, precomputed data, or changes to the data model.

Some options may be eliminated quickly.

The important part is avoiding the assumption that the first technically plausible solution is automatically the right one.

Evaluate Quality Attributes

For each realistic option, consider the qualities that matter to the system.

How does it affect scalability?

How does it affect maintainability?

What happens when it fails?

Does it create a new bottleneck?

Can the current team operate it?

How difficult will it be to test?

What will it cost?

Architecture is often the process of discovering that improving one property affects another.

Consider Technical Debt

Sometimes the fastest solution is still the right solution.

If so, the compromise should be understood.

A temporary shared database, duplicated piece of logic, or direct integration may allow an important feature to ship quickly.

The architectural question is whether the future cost is acceptable.

Intentional debt is much easier to manage than accidental debt because the team knows what compromise was made and why.

Compare the Trade-Offs

At this point, the decision should be framed in terms of consequences rather than preferences.

Instead of:

"Event-driven architecture is more scalable."

the reasoning might be:

Option A: synchronous API

+ simpler workflow
+ immediate result
+ easier debugging
- caller waits for processing
- runtime dependency between services


Option B: asynchronous event

+ caller doesn't wait
+ consumers can process independently
+ easier to add additional consumers
- eventual consistency
- retries and duplicate handling
- harder end-to-end debugging

Now there is something concrete to evaluate.

Make the Decision

Eventually, one option has to be chosen.

The goal isn’t to prove that the selected option has no disadvantages. Every meaningful architectural choice has disadvantages.

The goal is to decide that its disadvantages are acceptable given the requirements.

This is a subtle but important difference.

A strong architectural decision doesn’t say:

This approach is best.

It says:

Given these requirements and constraints, this approach gives us the trade-offs we are willing to accept.

Document Significant Decisions

Important architectural decisions are easy to forget.

Six months later, a developer may look at an unusual part of the system and wonder why it was designed that way.

Without context, they may remove something that solved an important constraint or preserve something whose original constraint no longer exists.

One common way to avoid this is an Architecture Decision Record, or ADR.

An ADR doesn’t need to be a huge document.

A simple version might record:

Decision:
Process uploaded videos asynchronously.

Context:
Large uploads cause request timeouts and processing
load varies significantly throughout the day.

Decision:
Store uploads first and submit processing jobs
to a queue consumed by independent workers.

Consequences:
+ uploads return quickly
+ workers scale independently
- processing becomes eventually consistent
- queue and worker failures require monitoring

The value isn’t the format.

It’s preserving the reasoning.

Architecture diagrams show what the system looks like. Decision records help explain why it looks that way.

Revisit Decisions as the System Grows

Architectural decisions aren’t permanent simply because they were correct when they were made.

Requirements change.

A system designed for thousands of users may reach millions. A dependency may become unreliable. A managed service may become too expensive. A team may grow. A business may enter regions with new regulatory requirements.

When the context changes, old decisions should be reconsidered against the new context.

That doesn’t mean constantly redesigning the system.

It means recognizing that architecture is an ongoing process rather than a one-time phase at the beginning of development.

The complete cycle looks something like this:

       ┌────────────────────┐
       │   Business Need    │
       └─────────┬──────────┘

       ┌────────────────────┐
       │    Requirements    │
       └─────────┬──────────┘

       ┌────────────────────┐
       │  Design Decision   │
       └─────────┬──────────┘

       ┌────────────────────┐
       │   Implementation   │
       └─────────┬──────────┘

       ┌────────────────────┐
       │ Production Reality │
       └─────────┬──────────┘

          requirements change

                 └──────────────► repeat

This is why software architecture is less about memorizing architecture patterns and more about learning how to reason about systems.

Layered architecture, microservices, event-driven systems and client-server architecture are all useful models. Scalability, maintainability and reliability are all important qualities. Modularity, loose coupling and high cohesion are valuable principles.

But none of them tells you what architecture a particular system should have.

That answer comes from understanding the problem, identifying the constraints, considering realistic options, and accepting the trade-offs that best support what the system needs to accomplish.

Good software architecture is not about choosing the most sophisticated pattern. It is about making deliberate design decisions that create a system structure capable of meeting today’s requirements while remaining able to change.