CMP-340 · Lesson 6 of 12 · 30 min

Software architecture

Architecture is the high-level structure of a software system: its major parts, how they relate to each other and to the environment, and the principles that guide its design and evolution. Grady Booch put it practically: architecture represents the significant design decisions, where significance is measured by the cost of change. Choosing a programming language, splitting a system into separately deployed services, picking a database, deciding how components communicate: these are hard to reverse, so they deserve deliberate thought. This lesson covers how quality attributes drive architecture, the main architectural styles and their trade-offs, how to document decisions, and how to keep an architecture healthy as the system grows. CMPUT-404 goes deeper into the architecture of web applications, and CMPUT-450 into distributed systems; ROB-499 applied architecture to robots.

Quality attributes drive architecture

Almost any architecture can provide the required functions. What distinguishes architectures is how well they deliver the non-functional requirements from lesson 3: performance, availability, scalability, security, modifiability, testability, deployability and cost. These quality attributes often conflict. Adding encryption and authentication checks improves security but costs performance. Splitting a system into many services improves independent deployability but makes performance and debugging harder. Caching improves performance but makes data consistency harder. Architecture is the art of choosing which trade-offs to make for a particular system, and the Software Engineering Institute's architecture tradeoff analysis method (ATAM) formalizes this by evaluating candidate architectures against concrete quality-attribute scenarios.

A quality-attribute scenario makes an attribute concrete: a source, a stimulus, the part of the system affected, the environment, the response and a measurable response measure. 'When a developer changes the format of the monthly report, during normal development, the change affects only the reporting module and is completed, tested and deployed within one day' is a modifiability scenario. 'When the email provider fails during the Saturday-morning peak, sign-ups still succeed and reminders are retried within 15 minutes' is an availability scenario. Scenarios like these let a team judge whether an architecture is good enough, rather than arguing in the abstract.

Quality attributes, tactics and their usual costs
Quality attributeTactics that improve itWhat it often costs
PerformanceCaching, indexing, concurrency, reducing network hopsComplexity, stale data, memory
AvailabilityRedundancy, health checks, retries, graceful degradationCost, consistency challenges
ScalabilityStateless services, horizontal scaling, partitioning dataOperational complexity
SecurityAuthentication, authorization, encryption, least privilege, input validationPerformance, usability
ModifiabilityModularity, information hiding, stable interfaces, configurationIndirection, initial effort
TestabilityDependency injection, separating logic from input and output, deterministic componentsExtra interfaces
DeployabilityAutomated pipelines, independent services, feature flagsInfrastructure and tooling effort

Layered architecture

A layered architecture divides a system into horizontal layers, each providing services to the layer above and using only the layer below. A typical business application has a presentation layer (user interface or API), an application or service layer (use cases and workflow), a domain layer (business rules) and a data access or infrastructure layer (databases, email, external services). The rule that dependencies point only downward means each layer can be understood, tested and replaced with knowledge only of the layer beneath it. The network protocol stack from CMPUT-313 and the operating system layers of CMPUT-379 are classic examples.

Layering's main risk is erosion: shortcuts in which the user interface talks directly to the database, or the domain layer imports web framework code. Each shortcut seems harmless, but together they turn the layers into a tangle. The fix is to check the rule automatically. The script below analyses the imports in a small package and reports any that point the wrong way; tools such as import-linter for Python, ArchUnit for Java and dependency-cruiser for JavaScript do the same in real projects as part of continuous integration.

Checking layer rules automatically: one import points upward and one skips a layer
import ast, pathlib, tempfile

# A tiny project with four layers; one module breaks the rules
files = {
    "ui/pages.py":        "from app.signup import sign_up\nfrom data.repo import ShiftRepo\n",
    "app/signup.py":      "from domain.rules import can_sign_up, ShiftRepository\n",
    "domain/rules.py":    "def can_sign_up(confirmed, capacity):\n    return confirmed < capacity\n",
    "data/repo.py":       "import sqlite3\nfrom domain.rules import can_sign_up\n",
}
order = ["ui", "app", "domain", "data"]       # each layer may import only layers below it
root = pathlib.Path(tempfile.mkdtemp())
for name, text in files.items():
    (root / name).parent.mkdir(parents=True, exist_ok=True)
    (root / name).write_text(text)

for path in sorted(root.rglob("*.py")):
    layer = path.relative_to(root).parts[0]
    for node in ast.walk(ast.parse(path.read_text())):
        if isinstance(node, ast.ImportFrom) and node.module:
            target = node.module.split(".")[0]
            if target in order:
                ok = order.index(target) > order.index(layer)
                if not ok or order.index(target) - order.index(layer) > 1:
                    kind = "skips a layer" if ok else "points upward"
                    print(f"{path.relative_to(root)}: imports {node.module} ({kind})")
Output
data/repo.py: imports domain.rules (points upward)
ui/pages.py: imports data.repo (skips a layer)

The check reports two problems. The user interface reaching straight into the data layer skips the application layer, which is sometimes tolerated as a relaxed layering but here bypasses the business rules. The data layer importing the domain rules points upward, a dependency that makes the domain impossible to test without the database. Some architectures, such as hexagonal architecture (also called ports and adapters, Alistair Cockburn) and Robert Martin's clean architecture, invert that last relationship deliberately: the domain sits at the center and defines interfaces, called ports, which the database and user interface implement as adapters. Dependencies then point inward toward the business rules, which become testable with no database at all. This is the dependency inversion principle from CMPUT-274 lesson 5, applied at the scale of a whole system.

Three small diagrams side by side. Layered: four stacked boxes, presentation, application, domain and data, with arrows pointing down. Hexagonal: a domain core in the center with ports on its edge, and adapters for web, database and email outside, with arrows pointing inward. Microservices: separate boxes for sign-up, notification and reporting services, each with its own database, connected through an API gateway and a message broker.
Three architectural styles: layered, hexagonal (ports and adapters) and microservices

Client–server, MVC and pipes and filters

In a client–server architecture, clients request services from servers over a network. The web is the canonical example: browsers are clients and web servers respond to HTTP requests. Most modern systems add tiers: a browser or mobile client, an application server and a database server, the classic three-tier architecture. Model–view–controller (MVC) separates an interactive application into the model (data and rules), views (presentations of the model) and controllers (which handle input and update the model). Web frameworks such as Django, Rails and Laravel use variants of MVC, and the separation is what lets the same model serve a web page, a mobile API and a report.

Pipes and filters structures a system as a chain of independent processing steps, each reading input, transforming it and writing output. Unix command pipelines are the classic example, and so are compilers (lexer, parser, optimizer, code generator, as CMPUT-415 will show) and data processing jobs. Filters can be developed, tested and reused independently, and some can run in parallel. Python generators make an elegant pipeline because each filter processes one item at a time and passes it on.

Pipes and filters with generators: each step is small, independent and testable
import csv, io

raw = """volunteer,program,date,hours
Ana,Pantry,2026-10-03,3
Ben,Pantry,2026-10-03,
Chloe,Delivery,2026-10-04,4.5
Ana,Delivery,2026-10-10,2
Dev,Pantry,2026-11-01,3
"""

def read(text):                       # source filter
    yield from csv.DictReader(io.StringIO(text))

def valid(rows):                      # drop rows with missing hours
    for r in rows:
        if r["hours"].strip():
            yield r

def in_month(rows, month):            # keep one month
    for r in rows:
        if r["date"].startswith(month):
            yield r

def totals(rows):                     # sink: aggregate hours by program
    out = {}
    for r in rows:
        out[r["program"]] = out.get(r["program"], 0) + float(r["hours"])
    return out

print(totals(in_month(valid(read(raw)), "2026-10")))
Output
{'Pantry': 3.0, 'Delivery': 6.5}

Event-driven architecture

In an event-driven architecture, components communicate by publishing events, records that something happened, such as ShiftSignedUp or ShiftCancelled, rather than by calling each other directly. Other components subscribe to the events they care about. The publisher does not know who is listening, so new behavior can be added without changing it: a reminder service, a statistics service and an audit log can all react to ShiftSignedUp independently. Events usually travel through a message broker such as RabbitMQ, Apache Kafka or a cloud queue, which also buffers them when a subscriber is slow or down. The costs are that the overall flow becomes harder to follow, failures and retries must be designed carefully (an event may be delivered twice, so handlers should be idempotent), and data across services becomes eventually consistent rather than instantly consistent.

Publish and subscribe: the publisher does not know its subscribers, and duplicates are ignored
from collections import defaultdict

class EventBus:
    def __init__(self):
        self.subscribers = defaultdict(list)
        self.seen = set()

    def subscribe(self, event_type, handler):
        self.subscribers[event_type].append(handler)

    def publish(self, event_type, event_id, **data):
        if event_id in self.seen:            # idempotence: ignore duplicate deliveries
            print(f"  (duplicate {event_id} ignored)")
            return
        self.seen.add(event_id)
        for handler in self.subscribers[event_type]:
            handler(**data)

bus = EventBus()
stats = {"signups": 0}
bus.subscribe("ShiftSignedUp", lambda who, shift: print(f"reminder scheduled for {who}, {shift}"))
bus.subscribe("ShiftSignedUp", lambda who, shift: stats.update(signups=stats["signups"] + 1))
bus.subscribe("ShiftSignedUp", lambda who, shift: print(f"audit: {who} signed up for {shift}"))

bus.publish("ShiftSignedUp", "e1", who="Ana", shift="Sat 09:00")
bus.publish("ShiftSignedUp", "e2", who="Ben", shift="Sat 09:00")
bus.publish("ShiftSignedUp", "e2", who="Ben", shift="Sat 09:00")   # redelivered by the broker
print("signups counted:", stats["signups"])
Output
reminder scheduled for Ana, Sat 09:00
audit: Ana signed up for Sat 09:00
reminder scheduled for Ben, Sat 09:00
audit: Ben signed up for Sat 09:00
  (duplicate e2 ignored)
signups counted: 2

Monoliths and microservices

A monolith is a system deployed as a single unit, even if its code is well modularized inside. A microservice architecture splits the system into small services, each owning its data, deployed independently and communicating over the network through APIs or events. Microservices became popular in the 2010s at companies such as Netflix and Amazon because they let many teams deploy independently and scale parts of a system separately. Amazon's rule that teams communicate only through service interfaces, and Conway's law, Melvin Conway's 1968 observation that systems tend to mirror the communication structure of the organizations that build them, explain much of the appeal: independent services match independent teams.

Microservices carry real costs: network calls are slower and less reliable than function calls, data is spread across services so transactions across them are hard, debugging needs distributed tracing, and operations need container orchestration, service discovery and monitoring. For a small team or a new product, these costs usually outweigh the benefits. Martin Fowler's advice of 'monolith first' is widely followed: start with a well-modularized monolith, and split out services when a part clearly needs independent scaling or deployment. Several well-known companies have moved some systems back from microservices to modular monoliths after finding the overhead too high. Availability arithmetic shows one of the hidden costs: when a request must pass through several services in series, their availabilities multiply.

Calls through several services multiply their availabilities; redundancy recovers it
# Availability of components in series and with redundancy
def series(*a):
    p = 1.0
    for x in a:
        p *= x
    return p

def redundant(a, copies):
    return 1 - (1 - a) ** copies

minutes_per_month = 30 * 24 * 60
for label, avail in [("one service at 99.9%", 0.999),
                     ("five services in series at 99.9% each", series(*[0.999] * 5)),
                     ("five in series, each with 2 redundant copies",
                      series(*[redundant(0.999, 2)] * 5))]:
    down = (1 - avail) * minutes_per_month
    print(f"{label:46s} {avail:.5%}  about {down:5.1f} min down per month")
Output
one service at 99.9%                           99.90000%  about  43.2 min down per month
five services in series at 99.9% each          99.50100%  about 215.6 min down per month
five in series, each with 2 redundant copies   99.99950%  about   0.2 min down per month
Monolith versus microservices
ConcernModular monolithMicroservices
DeploymentOne unit; simple pipelineEach service independently; many pipelines
Team scalingCoordination needed for releasesTeams deploy independently
PerformanceIn-process callsNetwork calls, serialization, latency
Data consistencyOne database, ordinary transactionsEach service owns data; eventual consistency
ScalingScale the whole applicationScale hot services separately
OperationsModestOrchestration, tracing, service discovery
Good fitSmall and medium teams, new productsLarge organizations with many independent teams

Documenting architecture

Architecture documentation should help people understand and change the system. Two lightweight forms work well. The first is a small set of diagrams at different zoom levels, such as the C4 model from lesson 5: a context diagram showing the system and its users and neighbors, a container diagram showing the deployable parts and their technologies, and component diagrams for the interesting parts. The second is a log of architecture decision records (ADRs), a format described by Michael Nygard in 2011. Each ADR is a short text file in the repository recording one significant decision: the context, the decision, its status and its consequences. ADRs answer the question every new team member eventually asks, 'why did we do it this way?', and they make it easier to revisit a decision when its context changes.

An architecture decision record (ADR), stored as a Markdown file in the repository. Listing only.
# ADR 0004: Use a modular monolith with PostgreSQL

Status: Accepted (2026-10-12)

## Context
Three developers will build the volunteer scheduling system over two terms.
Expected load is at most a few hundred concurrent users. The food bank's IT
volunteer can operate one application server and one database, not a cluster.

## Decision
Build a single deployable Django application organized into modules
(accounts, shifts, notifications, reporting) with dependencies checked in CI.
Use PostgreSQL. Send notifications through a background job queue in the
same deployment.

## Consequences
+ One pipeline, simple operations, ordinary database transactions.
+ Module boundaries let us extract notifications as a service later if needed.
- All modules scale together; acceptable at the expected load.
- Module boundaries must be enforced by tooling, or they will erode.

Worked problem

Choosing an architecture from scenarios

A province or state wants an online system for booking driving tests. Scenarios: (1) on the first business day of each month, 20,000 people try to book within an hour; (2) the examiner scheduling rules change twice a year; (3) the payment provider is occasionally unavailable for minutes at a time; (4) the team has eight developers. Recommend an architecture and justify each choice against a scenario.

Show solution
  1. Scenario 4 suggests a modular monolith rather than microservices: eight developers can coordinate one deployment, and the overhead of many services is not justified.
  2. Scenario 1 is a burst-load problem. Keep the booking module stateless so it can scale horizontally behind a load balancer for the peak, use a queue or waiting room for bursts, and protect the database with short transactions and an index on available slots.
  3. Scenario 2 calls for modifiability: put scheduling rules in the domain layer behind a clear interface, possibly as configuration, so changes touch one module and are covered by tests.
  4. Scenario 3 calls for availability tactics: hold the slot for a few minutes, call the payment provider with timeouts and retries, and publish a PaymentFailed event that releases the hold, so outages do not corrupt bookings.

Answer: A layered or hexagonal modular monolith with a stateless, horizontally scalable booking module, rules isolated in the domain layer, and resilient, event-driven handling of payments. Each decision traces to a scenario, which is what an ADR would record.

Keeping architecture healthy

Architectures erode as shortcuts accumulate. Healthy teams protect the important boundaries with automated checks such as the import analysis above, review architecture changes explicitly, record decisions in ADRs, and measure warning signs: modules that change together in most commits, growing cycles in the dependency graph, and classes that everything depends on. Lesson 7 looks at coupling and cohesion inside modules; the same ideas apply at the architectural scale. Finally, architecture is not only for architects. On most agile teams, every developer makes architectural decisions, and the team owns the architecture together, which is why everyone needs to understand the trade-offs in this lesson.

Common mistakes

  • MistakeChoosing microservices because large companies use them.

    InsteadStart with a modular monolith unless independent deployment or scaling clearly justifies the overhead of distributed services.

  • MistakeDesigning only for functional requirements.

    InsteadQuality attributes such as availability, performance and modifiability determine the architecture; express them as concrete scenarios.

  • MistakeLetting layer rules erode through small shortcuts.

    InsteadCheck dependency rules automatically in continuous integration.

  • MistakeAssuming events are delivered exactly once.

    InsteadBrokers may redeliver; make event handlers idempotent and design for retries.

  • MistakeLeaving architectural decisions undocumented.

    InsteadRecord significant decisions and their reasons in short architecture decision records in the repository.

Summary and key terms

  • Architecture is the set of significant design decisions about a system's structure, judged by how costly they are to change.
  • Quality attributes drive architectural choices and often conflict; quality-attribute scenarios make them concrete and testable.
  • Layered architectures restrict dependencies to point downward; hexagonal and clean architectures point dependencies inward toward the domain.
  • Client–server, MVC and pipes and filters are common styles; event-driven architectures decouple publishers from subscribers at the cost of eventual consistency.
  • Microservices allow independent deployment and scaling but add network, data and operational complexity; "monolith first" is sound advice for most new systems.
  • Availabilities of components in series multiply; redundancy is the main tactic for recovering availability.
  • Architecture is documented with a few diagrams and architecture decision records, and protected from erosion by automated checks.
Software architecture
The high-level structure of a system and the significant decisions that shape it.
Quality attribute
A measurable property such as performance, availability or modifiability.
Quality-attribute scenario
A concrete statement of a stimulus, environment, response and response measure for a quality.
Layered architecture
A style in which each layer uses only the layer below it.
Hexagonal architecture
A style that places the domain at the center, with ports implemented by adapters for external systems.
Pipes and filters
A style that chains independent processing steps.
Event-driven architecture
A style in which components communicate by publishing and subscribing to events.
Microservice
A small, independently deployable service that owns its data.
Conway's law
Systems tend to mirror the communication structure of the organizations that build them.
Architecture decision record
A short document recording a significant decision, its context and consequences.

Practice problems

Work each problem on paper first, then reveal the answer to check.

  1. Write an availability scenario for a payment step.

    Show answer

    For example: when the payment provider is unreachable during peak hours, bookings are held for 10 minutes and retried, and no booking is lost or double-charged.

  2. Name two tactics for performance and one cost of each.

    Show answer

    Caching (stale data); adding concurrency (complexity and race conditions).

  3. Why do hexagonal architectures make the domain easy to test?

    Show answer

    The domain depends only on its own interfaces (ports), so tests can supply simple fake adapters instead of a database or web server.

  4. Give an example of Conway's law.

    Show answer

    A company with separate front-end, back-end and database teams tends to build a three-layer system with heavy handoffs between those layers.

  5. Three services in series each have 99.5 percent availability. What is the combined availability?

    Show answer

    0.995 cubed is about 98.5 percent.

  6. What makes an event handler idempotent, and why does it matter?

    Show answer

    Processing the same event twice has the same effect as once, for example by tracking event IDs; brokers can redeliver events.

  7. List the four parts of a Nygard-style ADR.

    Show answer

    Context, decision, status and consequences (often with a title and date).

  8. When might a team reasonably extract a microservice from a monolith?

    Show answer

    When one part needs independent scaling, a different release cadence, a different technology, or is owned by a separate team.

  9. What does MVC separate?

    Show answer

    The model (data and rules), views (presentation) and controllers (input handling).

  10. How can a team detect architectural erosion automatically?

    Show answer

    Dependency-rule checks in CI, detecting import cycles, and tracking modules that frequently change together.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz