Software Architecture Fundamentals
A Complete 10-Chapter Software Development Course
Table of Contents
- Why Software Architecture Matters
- Layered Architecture
- MVC and Its Variants
- Monolith vs. Microservices
- Finding Service Boundaries
- Event-Driven Architecture
- Hexagonal / Clean Architecture
- Client-Server & API-Centric Architecture
- Documenting Architecture: ADRs and the C4 Model
- Capstone — Designing the Architecture for a Real Application
Why Software Architecture Matters
Software Architecture Fundamentals
Chapter 1 · Why Software Architecture Matters
This course is a direct sequel to Design Patterns — but it operates at a genuinely different scale. A pattern solves a local problem: how two or three objects collaborate. Architecture solves a system-shape problem: how an entire codebase is organized, and — the specific thing this chapter measures — how expensive a given decision is to change once the system has grown around it.
Architecture vs. Design vs. Implementation
These three terms get used loosely, but they describe genuinely different scopes of decision, distinguished by the same question at every level: how much of the codebase does changing your mind touch?
| Level | Example decision | Typical blast radius of changing it |
|---|---|---|
| Implementation | Using a for loop instead of a list comprehension inside one function | One function |
| Design (patterns) | Which discount Strategy object an Order currently holds | One line — verified below |
| Architecture | Whether business logic talks to storage directly, or through one boundary | Every function that touches storage — verified below |
A Local Decision, Verified Cheap to Change
Design Patterns Chapter 7 built exactly this kind of local decision: an Order holding a swappable shipping_strategy. That chapter verified something directly relevant here — swapping StandardShipping for ExpressShipping on an already-created Order object took one line (order.set_shipping_strategy(ExpressShipping())), and every other part of the codebase was completely unaffected. That's a design-level decision: cheap, local, reversible.
This chapter measures the opposite case — a decision made at the architecture level, where getting the boundary wrong makes an otherwise-simple change expensive.
An Architectural Decision, Measured
Without a Boundary: Storage Woven Directly Into Business Logic
open( (Python's own file-open call) finds it in 3 of 3 — every single function. There is no single place in this codebase that "does storage" — the decision to use flat text files is scattered across every function that happens to need persistence.
With a Boundary: One Repository, Everything Else Unchanged
open( now finds it in 0 of 3. All storage-specific code lives in exactly one place: FileOrderRepository.
InMemoryOrderRepository (same three methods, backed by a Python list instead of a file), and injecting it in place of FileOrderRepository: both repositories were captured via Python's own inspect.getsource() before and after the swap. The three business functions' own source text was confirmed character-for-character identical before and after — record_order, record_payment, and generate_report were never opened, let alone edited. Calling generate_report(file_repo) and generate_report(memory_repo) with the same recorded order both correctly returned ['ORD-1,100'].
Why This Gets Worse, Not Better, as the Codebase Grows
record_refund, generate_payment_report, generate_refund_report, each with their own direct open() call) produced 6 of 6 functions needing a touch to swap storage — the same 1:1 ratio as the original 3-function version. The boundary-with version's own cost stays flat at 0 business-function touches regardless of how many functions call the repository — only the one-time cost of writing a new repository class changes.
Repository) is one instance of a much more general idea this course returns to in every chapter: Layered Architecture (Chapter 2) and Hexagonal Architecture (Chapter 7) are both, at heart, systematic ways of making sure this kind of boundary exists before you need it, not after.
Where This Connects
| This chapter's finding | What it sets up |
|---|---|
| Design Patterns Chapter 7's one-line Strategy swap, reused directly as the "cheap" baseline | Every pattern in that course operates at this same low-cost, local scale — architecture is the layer above it, not a replacement for it |
| The Repository boundary keeping business logic at 0 touches | Chapter 2's Layered Architecture generalizes this one boundary into a full, named set of layers |
| An unexamined decision (no boundary) costing 1 touch per function, forever | Chapter 9's Architectural Decision Records exist specifically to make a decision like "do we need a boundary here" deliberate and recorded, not accidental |
Hands-On Exercises
Add a fourth boundary-free function, record_refund(order_id, amount), to this chapter's own first (no-repository) example, using the same direct open() pattern. Verify the touch count is now 4 of 4.
Add a matching save_refund(order_id, amount) method to this chapter's own FileOrderRepository and InMemoryOrderRepository, plus a new business function record_refund(repo, order_id, amount) that calls it. Verify this new function's source contains no open( call, and that it works correctly against both repositories.
Using this chapter's own compare-table (Implementation / Design / Architecture), classify each of the following as one of the three levels, and justify your answer using this chapter's own "how much of the codebase does changing your mind touch?" test: (a) renaming a local variable inside one function, (b) switching an Order's discount strategy at runtime, (c) deciding whether a system is one monolith or split into several services.
Chapter 1 Quick Reference
- The test: a decision's level (implementation / design / architecture) is measured by how much of the codebase changing your mind touches — not by how important it sounds
- Verified: a design-level decision (Design Patterns' own Strategy swap) cost 1 line; an architecture-level decision made without a boundary cost 3 of 3 (then 6 of 6) function touches; the identical decision made with one boundary (a Repository) cost 0 business-function touches, confirmed via character-identical source text before and after the swap
- Next chapter: Layered Architecture — generalizing this one boundary into a full, named set of layers
Layered Architecture
Software Architecture Fundamentals
Chapter 2 · Layered Architecture
Chapter 1 put exactly one boundary in front of storage — a Repository — and measured what that boundary bought. Layered architecture takes that same idea and generalizes it into a full stack: presentation (what the user sees or calls), business logic (the rules), and data (storage). This chapter verifies what goes wrong, concretely, when a layer's own boundary gets skipped — in either direction.
The Three Layers
| Layer | Owns | Should never contain |
|---|---|---|
| Presentation | Formatting output, accepting input | Business rules (discount math, validation logic) |
| Business | The rules — discounts, validation, calculations | Storage details (file formats, SQL, an ORM's own API) |
| Data | Reading and writing storage, exactly as it's asked | Business rules (a discount, a validity check) |
Reusing Chapter 1's own order-processing example, extended into all three layers:
Strict Layering, and What Skipping It Actually Costs
Strict layering means each layer only ever talks to the layer directly beneath it — presentation calls business, business calls data, and presentation never reaches past business straight into data. Relaxed layering deliberately allows presentation to call data directly for cases with genuinely no business logic involved (a simple read-only lookup, say) — a legitimate choice, if it's made deliberately. The version below isn't that: it's an accidental bypass of a layer that actually owns real logic.
100 from a loyalty member and displaying it two ways: display_order_total_correct() (going through OrderService) correctly reports $90.00 — the 10% loyalty discount applied. display_order_total_broken() (reading straight from OrderRepository) reports $100.00 — the exact raw, undiscounted number, because the one place that knew about the loyalty discount was never consulted. Both functions are correct code — neither raises an error — but one of them is silently wrong, purely because of which layer it talked to.
The Other Direction: When Business Logic Leaks Downward
Layer violations don't only run "upward, skipping down" — they can run the other way too, when a lower layer starts making decisions that belong to the layer above it.
100 for a loyalty member, then calling audit_raw_total() — a function whose entire purpose is reading the true, unmodified figure for accounting — returns 90.0, not 100. The data layer's own get() silently applied a discount before any caller ever saw the number, off by exactly 10.0 from the true value. There is no longer any way to ask this repository for the raw total at all — the business rule baked into the data layer took that option away from every caller, including ones that specifically needed it.
Where This Shows Up in Familiar Frameworks
Most web frameworks already impose some version of this split, even if the layer names differ — Django's models/views split, a typical Express app's routes/controllers/models split, and Rails' own MVC convention are all recognizable variants of presentation/business/data. Chapter 3 looks specifically at the presentation-side variant of this — MVC, MVP, and MVVM — in depth.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| One boundary (Chapter 1) generalized into three named layers | Chapter 7's Hexagonal Architecture takes the same boundary idea further — a business layer that doesn't even know which specific data layer it's talking to |
| Skipping a layer producing a silently wrong number, not a crash | Technical Support's own `appdiag1` — a wrong result with no error is exactly the class of bug that course's own diagnostic chapters are built to catch |
| Business logic leaking into the data layer, removing a caller's ability to get the raw value | Chapter 5's service-boundary criteria (coupling and cohesion) — this leak is a concrete instance of low cohesion inside the data layer |
Hands-On Exercises
Add a second business rule to OrderService.get_order_total(): a flat $5 off any order over $50, applied after any loyalty discount. Verify the correct presentation function reports the right total, and that the broken (layer-skipping) presentation function is now wrong by even more than before.
Fix this chapter's own OrderRepositoryBad by moving its loyalty-discount logic out of get() and into a proper OrderService-style business layer, following this chapter's own OrderRepository/OrderService shape. Verify audit_raw_total() now correctly returns the true raw value.
This chapter's own text distinguishes a deliberate relaxed-layering choice (presentation reading directly from data for something with no business logic) from an accidental bypass (this chapter's own broken example). Using this chapter's own OrderRepository, write one new read-only method that would be genuinely safe for presentation to call directly, and explain specifically why it's safe where display_order_total_broken() wasn't.
Chapter 2 Quick Reference
- Three layers: Presentation (formatting/input), Business (the rules), Data (storage) — each should own exactly one kind of decision
- Verified — skipping upward: reading straight from the data layer instead of going through the business layer reported
$100.00instead of the correct$90.00— a silently wrong number, not a crash - Verified — leaking downward: baking a business rule into the data layer made the true raw value (
100) permanently unreachable — an audit function needing it got90.0instead, with no way to ask for the real number - Next chapter: MVC and Its Variants — the presentation-side version of this same layering question
MVC and Its Variants
Software Architecture Fundamentals
Chapter 3 · MVC and Its Variants
Chapter 2 said presentation should only ever "own formatting and input." That's true for all three variants in this chapter — MVC, MVP, and MVVM all agree the presentation layer shouldn't contain business rules. What they genuinely disagree on is how data actually flows between the view and everything behind it. This chapter builds all three, verifies the difference is real, and connects one of them directly back to a pattern you already know.
MVC: the View Reads the Model Directly
view.model is controller.model confirms True — the exact same object, not a copy. After controller.handle_add('Buy milk'), calling view.render() correctly reports "Todo list: Buy milk", purely because render() reads self.model.items directly at render time. The Controller never told the View anything — the View simply looked.
MVP: the View Never Touches the Model at All
MVP takes coupling the View to the Model away entirely — the View becomes "dumb," exposing only display methods, and a Presenter mediates every single interaction.
hasattr(view, 'model') returns False — unlike TodoView, TodoPresenterView was never given a reference to any model at all. Calling presenter.handle_add('Buy milk') correctly updates view.displayed_text to "Todo list: Buy milk" — but only because the Presenter explicitly called view.show_items(...) itself.
model.add_item('Walk the dog') directly — skipping the Presenter entirely — correctly updates model.items to ['Buy milk', 'Walk the dog']. But view.displayed_text stays exactly as it was: "Todo list: Buy milk" — genuinely stale. In MVP, nothing updates the View except the Presenter explicitly telling it to.
MVVM: the View Binds to the ViewModel — and This Is Just Observer
MVVM solves MVP's own staleness problem, but not by adding more explicit push calls — by using exactly the mechanism Design Patterns Chapter 8 already built: Observer. The ViewModel is the subject; the bound view is an observer.
view_model.attach(bound_view), calling view_model.add_item('Buy milk') correctly updates bound_view.displayed_text to "Todo list: Buy milk" — and a second call, add_item('Walk the dog'), correctly produces "Todo list: Buy milk, Walk the dog". Inspecting TodoViewModel.add_item's own source confirms it never references bound_view or any specific view type by name — it only calls self._notify(), exactly like Stock.set_price() only ever called observer.update(...) on whatever was attached.
Comparing All Three
| MVC | MVP | MVVM | |
|---|---|---|---|
| Can the View read the Model directly? | Yes — verified: same object identity | No — verified: hasattr(view, 'model') is False | No — the View only ever sees what the ViewModel notifies it with |
| Who updates the View? | The View reads for itself, on demand | The Presenter, explicitly, every time | The binding mechanism, automatically |
| What happens if you bypass the mediator? | N/A — there's no separate mediator to bypass | The View goes stale — verified above | Impossible by construction — there's no separate "tell the view" step to skip |
| Common in | Classic Rails/Django-style server-rendered apps | Older desktop GUI frameworks, testable Android (pre-Compose) | WPF, and reactive/data-bound web frameworks (Vue, some React state libraries) |
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| MVVM's binding verified as literally Design Patterns' own Observer, reused unchanged | Confirms this course's own Chapter 1 claim directly — a design-level pattern (Observer) is one of the concrete mechanisms an architecture-level decision (MVVM) is built from |
| MVP's verified stale-view bug when the Presenter is bypassed | Chapter 2's own "skipping a layer produces a silently wrong result, not a crash" finding — the same shape of bug, one level up |
| All three variants agreeing presentation owns no business rules | Chapter 5's coupling/cohesion criteria — the real difference between MVC/MVP/MVVM is entirely about coupling direction, not about what belongs in which layer |
Hands-On Exercises
Add a remove_item(text) method to this chapter's own MVC TodoModel and TodoController, following the exact shape of add_item/handle_add. Verify view.render() correctly reflects the removal, with no changes to TodoView at all.
Add a remove_item(text) method to this chapter's own MVP TodoPresenter (and matching model support). Verify it correctly pushes the update to view.displayed_text, and then verify that calling model's own removal method directly, bypassing the Presenter, leaves the View stale again — exactly like this chapter's own add_item bypass.
Attach a second TodoBoundView to this chapter's own TodoViewModel (alongside the first). Verify calling view_model.add_item(...) once updates both bound views correctly, and explain — using this chapter's own comparison table — what the MVP equivalent of adding a second view would have required that MVVM didn't.
Chapter 3 Quick Reference
- MVC: the View reads the Model directly — verified: same object identity, no mediator involved
- MVP: a dumb View, mediated entirely by a Presenter — verified: the View has no Model reference at all, and goes genuinely stale if the Presenter is bypassed
- MVVM: the View binds to the ViewModel — verified as literally Design Patterns' own Observer pattern, reused directly; updates happen automatically, with no explicit push step to forget
- Next chapter: Monolith vs. Microservices — a much bigger-scale version of the same "who's allowed to talk to whom" question
Monolith vs. Microservices
Software Architecture Fundamentals
Chapter 4 · Monolith vs. Microservices
Chapter 3 was about who's allowed to talk to whom inside one process. This chapter asks the same question at a much bigger scale: should this system even be one process? A monolith puts everything in one deployable unit, talking via direct function calls. Microservices split it into several independently deployable processes, talking over the network. Both are legitimate — but this chapter measures, with real numbers, exactly what crossing that boundary costs, and what happens when a "microservices" system doesn't actually get the benefits it's paying that cost for.
The Measured Cost of a Network Boundary
A monolith's internal calls are direct function calls. A microservices system's calls cross a process boundary — even when both services happen to run on the same machine. How much does that boundary actually cost?
127.0.0.1 (not even a different machine — zero actual network hops), averaged 1,253.57 microseconds per call. That's the network-boundary version taking roughly 11,661× longer — for the same result, computed the same way, on the same machine. This is the honest, measured cost of choosing to split a system into separate processes, before counting anything else.
When Splitting Genuinely Pays Off
| Monolith | Microservices | |
|---|---|---|
| Deployment | One unit, deployed together | Each service deployed independently |
| Scaling | Scale the whole thing, even if only one part is under load | Scale just the part that needs it |
| Call cost | A function call — verified: ~0.11 microseconds | A network call — verified: ~1,253 microseconds, even on localhost |
| Team boundaries | Everyone works in the same codebase | Different teams can own different services independently |
| Failure isolation | One crash can take down the whole process | One service crashing doesn't necessarily crash the others |
Splitting genuinely pays off when different parts of a system need to scale, deploy, or fail independently — not by default, and not just because the codebase feels large.
The Distributed Monolith: Paying the Cost, Getting None of the Benefit
A distributed monolith looks like microservices — separate processes, separate deployments on paper — but is still tightly coupled underneath, the same way Chapter 2's layer violations were coupling hiding inside code that looked correctly organized.
Anti-Pattern 1: A Shared Database
InventoryServiceProcess's own team renames their field, product_id → sku, and ships a matching InventoryServiceProcessV2 — which works correctly. OrderServiceProcess is never touched, never redeployed. Calling its unchanged get_order_summary('ORD-1') now raises KeyError: 'product_id'. Two "independently deployable" services just proved they weren't independent at all — because both were reading the same shared table directly, exactly the cross-layer read Chapter 2 verified breaking a single process, just now breaking across a process boundary too.
Anti-Pattern 2: Chained Synchronous Calls
service_a() returns in effectively 0.0 ms. Making only service_c slow (an artificial 300ms delay, simulating a real downstream service under load) — with zero changes to service_a or service_b — makes service_a()'s own total response time 300.3 ms. A never called anything slow itself. It's simply waiting, synchronously, at the end of a chain, for a service two hops away that it may not even know exists.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A shared database breaking an "independent" service, reusing Chapter 2's own cross-layer-read shape | Chapter 5's coupling/cohesion criteria — the concrete test for whether a split is real |
| Chained synchronous calls making a slowdown cascade upstream | Distributed Systems & Scalability's own resilience-pattern chapter (circuit breakers, timeouts) — the direct fix for exactly this finding |
| The measured ~11,661× network-call overhead, even on localhost | Technical Support's own `perfdiag1`/`appdiag1` — this is precisely the kind of cost those courses' diagnostic chapters trace back to a specific hop |
Hands-On Exercises
Extend this chapter's own HTTP benchmark to 500 calls instead of 200 for both the direct and HTTP-served versions. Verify the per-call timings stay in the same rough range as this chapter's own 200-call result, and report the new overhead ratio.
📄 View solutionFix this chapter's own shared-database anti-pattern by giving InventoryServiceProcess exclusive ownership of product data (its own separate store, no longer inside shared_db), and having OrderServiceProcess call InventoryServiceProcess's own method instead of reading product_id directly. Verify the same field rename from this chapter no longer breaks OrderServiceProcess.
Add a fourth service, service_d, called synchronously by service_c (so the chain is now A → B → C → D). Make only service_d slow (a 300ms delay), with service_c itself fast. Verify service_a's total response time still reflects the full delay, now three hops away instead of two.
Chapter 4 Quick Reference
- Measured cost: a real localhost HTTP call averaged ~1,253 microseconds vs. a direct call's ~0.11 microseconds — roughly 11,661× slower for the identical result, before counting a single real network hop
- Distributed monolith, verified twice: a shared database let one service's own internal rename break another, unredeployed service; a chained synchronous call made a downstream slowdown become the top-level caller's own measured slowdown (300.3ms, two hops away)
- The actual question: not "monolith or microservices" as a default, but whether a specific part of the system genuinely needs independent scaling, deployment, or failure isolation badly enough to pay the measured network cost for it
- Next chapter: Finding Service Boundaries — the concrete criteria (coupling and cohesion) for deciding where a real split should go
Finding Service Boundaries
Software Architecture Fundamentals
Chapter 5 · Finding Service Boundaries
Chapter 4 asked whether a system should split at all. This chapter asks the harder, more useful question: where, specifically? The standard answer is "high cohesion within a boundary, low coupling across it" — but that's a definition, not a method. This chapter builds an actual, runnable technique for finding real boundaries in real code, and is honest about where that technique can fail.
Coupling and Cohesion, Defined Concretely
| Term | Concrete question it answers |
|---|---|
| Cohesion | Do the things inside one candidate group actually work with the same data? |
| Coupling | How many calls or direct data touches cross from one candidate group into another? |
High cohesion, low coupling means: group things that share data together, and minimize how often one group has to reach into another.
An 8-Function Domain, Two Candidate Groups
Two candidate groups: {calculate_order_total, apply_order_discount, validate_order_items} ("Order") and {get_user_profile, update_user_address, validate_user_email} ("User").
Method 1: A Real Call Graph, via Static Source Inspection
Rather than eyeballing it, build an actual dependency graph: scan each function's own source (via Python's inspect.getsource()) for calls to any other function in the domain.
apply_order_discount calls calculate_order_total (within Order); send_order_confirmation_email calls both calculate_order_total (Order) and get_user_profile (User). Classifying each function by which group(s) its own calls touch put all 3 pure-Order and all 3 pure-User functions correctly in their own groups, and flagged send_order_confirmation_email as a genuine boundary function — the only one, according to this method.
The Honest Gap: What the Call Graph Alone Misses
update_user_loyalty_points calls calculate_order_total (an Order function) — but it also does user['points'] = user.get('points', 0) + ..., directly mutating User data, without ever calling a User-group function to do it.
update_user_loyalty_points only calls into the Order group — so the calls-only method labels it "pure Order". But it directly reads and writes user[...], genuinely touching User data. The calls-only method's own coupling metric is blind to this, because it only tracks function calls, not direct data access — a real, verified limitation, not a hypothetical one.
Method 2: Adding Direct Data Access to the Analysis
A second, complementary scan: does a function's own source directly reference order[...]/order.get(...) or user[...]/user.get(...), regardless of what it calls?
update_user_loyalty_points touches user[...] directly. Combined with its own call into the Order group (calculate_order_total), the corrected classification is BOUNDARY, not "pure Order." The combined method correctly finds 2 boundary functions total — send_order_confirmation_email and update_user_loyalty_points — where the calls-only method found only 1, silently missing the second one.
What to Do With a Genuine Boundary Function
Both send_order_confirmation_email and update_user_loyalty_points exist specifically because "an order was placed" needs to trigger something in the User domain. Forcing either function to live entirely inside one service means that service has to directly reach into the other's data — exactly Chapter 4's shared-database anti-pattern, verified breaking an "independent" service. Chapter 6 covers the standard fix: instead of Order code directly touching User data, OrderService publishes an event ("an order was placed"), and UserService — which actually owns the loyalty-points and email logic — reacts to it independently.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| 2 verified boundary functions, needing logic from both domains | Chapter 6's Event-Driven Architecture — the standard way to let two domains react to each other without directly touching each other's data |
| The call-graph-only method's honest, verified blind spot | Chapter 4's shared-database anti-pattern — the same kind of hidden coupling this chapter's own combined method was built specifically to catch |
| Two independent, complementary measurement methods, combined for a more complete picture | Chapter 9's ADRs — a real service-boundary decision should record which method(s) were used, since (as verified here) a single metric alone can miss real coupling |
Hands-On Exercises
Add a ninth function, get_order_history_for_user(user, orders), that loops over orders calling validate_order_items on each, and also reads user['id'] directly. Run this chapter's own combined (calls + data) classification method on it and verify it's correctly flagged as a boundary function.
This chapter's own call-graph-only method has a known blind spot for direct data access. Construct a second, different example function that the call-graph-only method would also misclassify, and verify your example reproduces the same kind of gap.
📄 View solutionExplain, using this chapter's own two verified boundary functions, why neither one is a sign that the Order/User split is a bad idea — and what it WOULD mean if half of this domain's 8 functions had come back classified as boundary functions instead of just 2.
📄 View solutionChapter 5 Quick Reference
- Cohesion: do the things in one candidate group share the same data? Coupling: how many calls/data touches cross group lines?
- Verified — call graph alone: correctly grouped 6 of 8 functions and found 1 genuine boundary function (
send_order_confirmation_email) - Verified — the honest gap: the calls-only method missed a second real boundary function (
update_user_loyalty_points) because it mutated another domain's data directly, without a function call to catch it - Verified — combined method: correctly found both boundary functions once direct data access was measured alongside calls
- Next chapter: Event-Driven Architecture — the standard fix for what a genuine boundary function should actually become
Event-Driven Architecture
Software Architecture Fundamentals
Chapter 6 · Event-Driven Architecture
Chapter 5 ended with two verified boundary functions — send_order_confirmation_email and update_user_loyalty_points — that both needed direct knowledge of both the Order and User domains to work. This chapter rebuilds them without that direct knowledge, using the exact mechanism Design Patterns Chapter 8 already gave you: publish/subscribe.
The Event Bus: Observer, Generalized for Typed Events
UserService and EmailService subscribe to 'OrderPlaced' independently — OrderService never references either one by name:
OrderService's source via inspect.getsource(): the string 'UserService' appears False times, and 'EmailService' appears False times. Calling order_service.place_order('ORD-1', 100, 'USER-1') correctly awarded 10 loyalty points (100 // 10) to USER-1 and correctly queued the confirmation email — both through subscriptions OrderService has no knowledge of.
AnalyticsService.log_order as a new subscriber (via one bus.subscribe('OrderPlaced', analytics_service.log_order) call) and placing a second order correctly logged it — while OrderService's own code was never touched. This is the exact same "add an observer, zero changes to the subject" finding Design Patterns Chapter 8 verified for Stock, now applied one level up: to services instead of objects.
Comparing Chapter 5's Direct Version Against This Chapter's Event-Driven Version
| Chapter 5 — direct calls | Chapter 6 — pub/sub events | |
|---|---|---|
| Does OrderService know UserService exists? | Yes — send_order_confirmation_email calls get_user_profile directly | No — verified: zero source references |
| Adding a new reaction to "order placed" | Edit an existing function to add a new call | Add a new subscriber — verified: zero OrderService changes |
| What happens if a reaction is slow or fails? | The whole call chain waits or fails together (Chapter 4's cascading-call finding) | Isolated per subscriber — covered further in this course's own resilience-pattern chapter |
A Basic Event-Sourcing Example
Instead of only reacting to events as they happen, an EventLog can record every event permanently — making the events themselves the source of truth, not just a running total.
OrderPlaced events (100, 50, 75) through an EventBus that logs every event before dispatching it, replay_total_revenue(log) returns 225, matching a manual sum (100+50+75) exactly. Filtering the same log for USER-1's own events reconstructs their order count (2) and total spend (150) — every one of these numbers comes entirely from replaying the stored events, not from a separately-maintained total that could drift out of sync.
The Eventual Consistency Tradeoff, Measured
Chapter 4's cascading-call demo showed a synchronous chain making a caller wait for every downstream step. An asynchronous event bus avoids that wait — by genuinely not having the update happen yet.
order_service.place_order('ORD-1', 100, 'USER-1') against an AsyncEventBus returns successfully, and order_service.orders correctly shows the new order. But checking user_service.users immediately afterward shows {} — genuinely empty. Only after bus.process_queue() runs does user_service.users correctly show {'USER-1': {'points': 10}}. Between those two points, the system is honestly, verifiably inconsistent — the order is real, but the loyalty points aren't there yet.
send_order_confirmation_email/update_user_loyalty_points returned — but only by giving OrderService direct knowledge of both other domains. This chapter's event-driven version genuinely doesn't know or care who's listening, or when they'll get around to processing the event — and that's precisely what creates the gap just measured. Neither tradeoff is free; which one to accept depends on whether "the user sees their points immediately" or "OrderService never has to know UserService exists" matters more for a given system.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| The event bus verified as Observer, reused a second time (after MVVM in Chapter 3) | Confirms this course's own recurring theme — design-level patterns are the concrete building blocks architecture-level decisions are actually made of |
| A real, measured eventual-consistency window | Distributed Systems & Scalability's own CAP Theorem & Consistency Models chapter — this chapter's own small demo is a concrete instance of that larger tradeoff |
| Event-sourcing reconstructing exact totals purely from a log | Technical Support's own `log1`/`backup1` — an event log serving the same "the record is the source of truth" role a well-kept audit log serves there |
Hands-On Exercises
Add a fourth subscriber, InventoryService.reserve_stock, to this chapter's own synchronous EventBus example, subscribed to 'OrderPlaced'. Verify it fires correctly alongside the existing three subscribers, and confirm OrderService's own source still contains zero reference to it.
Using this chapter's own EventLog, write a replay_orders_over(event_log, threshold) function that reconstructs — purely by replaying the log, no running counter — the count of orders with items_total greater than threshold. Verify it against this chapter's own three-event log (100, 50, 75) with a threshold of 60.
Using this chapter's own verified eventual-consistency finding, explain what a user-facing "Order placed! Your loyalty points will update shortly" message is actually doing — and why Chapter 5's original direct-call version would never have needed a message like that at all.
📄 View solutionChapter 6 Quick Reference
- Event bus: publishers publish typed events with no knowledge of subscribers — verified:
OrderService's own source contained zero reference to either subscriber, and a third subscriber was added with zeroOrderServicechanges - Event sourcing: the event log itself is the source of truth — verified: exact revenue (
225) and per-user totals reconstructed purely by replaying stored events - Eventual consistency, measured: a real, verified window existed where
order_service.ordersreflected the new order butuser_service.userswas still empty, closed only once the queued event was processed - Next chapter: Hexagonal / Clean Architecture — pushing the same dependency-direction discipline even further
Hexagonal / Clean Architecture
Software Architecture Fundamentals
Chapter 7 · Hexagonal / Clean Architecture
Chapter 1's Repository was already an informal instance of this chapter's own idea — an abstraction the business logic depended on, with two swappable implementations behind it. Hexagonal (or "ports and adapters") architecture makes that pattern deliberate and universal: the business logic — the core — defines every external thing it needs as an abstract port, and every piece of infrastructure becomes an adapter plugging into a port from the outside. This chapter verifies the concrete payoff that buys.
Ports: Defined by the Core, Not by the Infrastructure
Two Adapters per Port: One Fast and Fake, One Real
PROD-1 (stock level 5, triggering the low-stock rule) through PricingEngine wired to fake adapters returns 114.99999999999999. The exact same call, through the exact same PricingEngine class, wired to "real" adapters instead, returns 114.99999999999999 — identical. Both correctly recorded the low-stock notification for PROD-1. PricingEngine's own logic never changed at all — only which adapter it was handed did.
Dependency Inversion, Verified — Not Just Named
PricingEngine's own source via inspect.getsource() for each concrete class name: 'FakeInventoryAdapter' → False, 'RealInventoryAdapter' → False, 'FakeNotificationAdapter' → False, 'RealNotificationAdapter' → False. PricingEngine only ever references InventoryPort and NotificationPort — abstractions it defines itself.
The Testability Payoff, Measured
calculate_price(), each constructing a fresh PricingEngine, through fake adapters: 0.06 ms total. The identical 50 calls through adapters simulating real I/O latency: 1,037.73 ms total. Testing against fakes was ~18,111× faster — for verifying the exact same business logic, with the exact same assertions.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
Chapter 1's Repository, generalized into named ports the core itself owns | Confirms this course's own recurring pattern — an early, informal boundary becomes a formal, named architectural style once its own payoff is measured directly |
| A ~18,111× testability speedup from swapping real adapters for fakes | Software Testing Strategy (still reserved) — this chapter's own fake/real distinction is exactly what that course's own test-double material builds on |
| Zero source references from the core to any concrete adapter, verified directly | Chapter 6's identical verification technique, applied to OrderService's own ignorance of its subscribers — the same proof technique, reused a second time |
Hands-On Exercises
Add a third port, DiscountPort, with a matching fake and "real" adapter (the real one adding a time.sleep(0.01)), and wire it into PricingEngine so a valid promo code applies an extra 5% off. Verify identical results from the fake and real versions, and confirm PricingEngine's own source still references no concrete adapter by name.
Re-run this chapter's own testability benchmark at N=200 instead of 50, for both fake and real adapters. Verify the speedup ratio stays in the same rough order of magnitude as this chapter's own ~18,111× result.
Using this chapter's own verified findings, explain specifically why PricingEngine being unable to reference RealInventoryAdapter by name is what makes the ~18,111× testability speedup possible — not just a separate, unrelated finding.
Chapter 7 Quick Reference
- Ports: abstract interfaces the core defines and depends on — never a concrete adapter
- Adapters: concrete implementations plugging into a port from the outside — verified: fake and "real" adapters produced the identical business result (
114.99999999999999) - Dependency inversion, verified:
PricingEngine's own source contained zero reference to any of its four concrete adapter classes - Measured payoff: the same business-logic test ran ~18,111× faster through fake adapters than through ones simulating real I/O
- Next chapter: Client-Server & API-Centric Architecture — REST as an architectural style in its own right
Client-Server & API-Centric Architecture
Software Architecture Fundamentals
Chapter 8 · Client-Server & API-Centric Architecture
"REST" gets used loosely to mean "an HTTP API with GET/POST/PUT/DELETE" — but its actual defining architectural constraint is statelessness: every request carries everything the server needs, and the server keeps no memory of a client between requests. This chapter verifies why that constraint matters, and then looks at the other half of client-server architecture: how much logic a client should hold at all.
Statelessness, Verified — Not Just Defined
'Widget' via StatefulCartService correctly reports ['Widget']. Creating a fresh instance of the same service (simulating a server restart — a new process, a load balancer routing to a different server, anything that discards in-memory state) and calling get_cart('SESSION-1') with the identical session ID returns [] — genuinely empty. The cart didn't survive.
StatelessCartService: adding 'Widget' returns ['Widget'], which the client holds onto. A fresh service instance, given that same ['Widget'] cart by the client itself, correctly returns ['Widget'] — because the server was never the one remembering it. Nothing about the server's own restart mattered, because nothing the request needed was stored there.
Thin vs. Thick Clients: Where Should the Logic Live?
A thick client implements business logic itself. A thin client only displays what the server tells it and sends raw requests. What happens when the same logic gets implemented twice, independently, by two thick clients?
$120 order for a loyalty member: mobile_client_calculate_total() returns 103.0; web_client_calculate_total() returns 103.5. Both teams implemented "10% loyalty discount, then $5 off orders over $100" — but disagreed on the order those two rules apply in, producing a genuine $0.50 discrepancy for the same customer, the same order, on two different platforms.
The Thin-Client Fix: One Server, Both Clients Call It
/calculate, backed by a single server_calculate_total() function, and having both a "mobile" and "web" client call it (instead of computing anything themselves): both correctly return 103.5 — identical. Neither client contains the discount logic at all anymore; both just display whatever the one authoritative implementation returns.
Where a Mobile App and a Web Frontend Both Fit
This is the actual payoff of API-centric architecture: build one stateless, thin-client-facing API, and let as many different client types as needed — a web frontend, a mobile app, a third-party integration — consume the identical endpoints. None of them need their own copy of the business logic, and none of them depend on the server remembering who they are between requests.
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A stateful cart losing data on a simulated restart | Distributed Systems & Scalability's own Load Balancing chapter — this is precisely the failure mode session affinity exists to work around, and statelessness avoids needing it at all |
| A verified $0.50 discrepancy between two independently-implemented thick clients | Chapter 5's coupling analysis — duplicated logic across two codebases is a coupling problem, even with no direct function call or shared data between them |
| One authoritative server endpoint resolving the discrepancy | Chapter 7's Hexagonal Architecture — the server's own server_calculate_total() is exactly the kind of core logic a real system would put behind ports, testable independently of any client |
Hands-On Exercises
Add a get_item_count(current_cart) method to this chapter's own StatelessCartService. Verify it works correctly even when called against a brand-new service instance (simulating another server restart), given only the cart data the client itself provides.
Using this chapter's own mobile_client_calculate_total() and web_client_calculate_total(), find a second input (a different items_total, still a loyalty member) where the two thick clients happen to agree, and explain specifically why the order-of-operations bug doesn't show up for that input.
This chapter's own server_calculate_total() matched the web client's own (correct-by-luck) order of operations, not the mobile client's. Explain why "the server happens to agree with one of the two clients" isn't actually the reason a thin-client design fixes the discrepancy — what's the real reason, using this chapter's own verified $120 example?
Chapter 8 Quick Reference
- Statelessness, verified: a stateful cart lost its contents (
[]) on a simulated server restart; the stateless version, given the same data by the client, survived unchanged - Thick clients, verified diverging: two independently-written clients returned
103.0vs.103.5for the identical $120 loyalty-member order — a real $0.50 discrepancy from independently-implemented rule ordering - Thin clients, verified converging: both clients returned the identical, correct
103.5once calling one shared server endpoint instead of implementing the logic themselves - Next chapter: Documenting Architecture — ADRs and the C4 Model, so a decision like "thin client, stateless API" gets recorded, not just made
Documenting Architecture: ADRs and the C4 Model
Software Architecture Fundamentals
Chapter 9 · Documenting Architecture: ADRs and the C4 Model
Chapters 1–8 made real architectural decisions — a repository boundary, layering, MVC's variants, event-driven boundaries, ports and adapters. None of it is worth anything to a future engineer unless it's written down in a way that actually answers their questions. This chapter measures the difference between a documented decision and a genuinely useful one.
Two ADRs for the Same Decision
Both records below document the exact same real decision this course made in Chapter 6 — but only one of them can actually answer a future engineer's questions.
BAD_ADR genuinely satisfies "was this decision documented? yes." But a new engineer reading it later still can't tell whether direct calls were considered and rejected, or never considered at all — the exact ambiguity this course's own Chapter 4 (cascading calls) and Chapter 5 (shared-database coupling) exist specifically to resolve. A record that only states the "what," never the "why" or "what it costs," is a paper trail, not documentation.
The C4 Model: Four Levels of Zoom
| Level | Shows |
|---|---|
| 1. Context | The system as one box, and who/what interacts with it |
| 2. Container | The major running pieces inside the system (services, databases, APIs) |
| 3. Component | The major building blocks inside one container |
| 4. Code | Class diagrams — rarely drawn by hand; usually generated from the code itself |
A lightweight Level 1 + Level 2 diagram for the actual system this course has been building since Chapter 1, rendered and visually verified:
OrderService alone would show Order, OrderBuilder, and the various strategy/state classes from Design Patterns — genuinely useful for someone working inside that one service, but unnecessary for someone trying to understand how the whole system fits together. Match the diagram's zoom level to the question actually being asked, the same way Chapter 5 matched its own analysis method to the question "where should the boundary go."
Where This Connects
| This chapter's finding | What it connects to |
|---|---|
| A verified 1-of-4 vs. 4-of-4 ADR answerability gap | Chapter 8's own Exercise 3 — a decision's specific value (which client ordering "wins") is separate from the decision to centralize it; an ADR is where that separation gets written down explicitly |
| The C4 diagram naming every component from Chapters 1–8 by its own chapter | This course's own capstone (Chapter 10) — the diagram doubles as a map of what the capstone project will assemble |
| A real layout bug caught by rendering the diagram, not just writing its markup | Pseudocode & Algorithmic Problem-Solving's own SVG-verification technique, reused directly rather than trusted blind |
Hands-On Exercises
Write a third ADR, PARTIAL_ADR, that includes a Context section (why direct calls were rejected) but omits the Consequences section entirely. Run this chapter's own four-question test against it and report which questions it can and can't answer.
Add a fifth question to this chapter's own QUESTIONS dictionary: "Who is allowed to publish an OrderPlaced event?" (keywords: "OrderService will publish"). Verify both BAD_ADR and GOOD_ADR against the expanded five-question set and report the new totals.
Using this chapter's own C4 diagram, explain which single component you'd need to zoom into with a Level 3 (Component) diagram to understand the discrepancy Chapter 8 verified between the mobile and web thick clients — and why the Level 2 diagram alone couldn't have shown that bug.
📄 View solutionChapter 9 Quick Reference
- An ADR is useful when it answers real future questions: verified — a vague ADR answered 1 of 4 test questions; a full Context/Decision/Consequences ADR answered 4 of 4
- C4 has four zoom levels: Context, Container, Component, Code — match the level to the question being asked, not to how detailed a diagram could theoretically be
- Verified: this course's own Level 1+2 diagram was rendered and screenshot-checked before finalizing, catching a real arrow-routing bug in the first draft
- Next chapter: the Capstone — designing the full architecture for a real application, using every chapter in this course together
Capstone — Designing the Architecture for a Real Application
Software Architecture Fundamentals
Chapter 10 · Capstone: Designing the Architecture for a Real Application
Every chapter in this course built one real, verified piece of the same underlying system. This capstone assembles all nine into a single, working whole — OrderRepository (Ch.1–2), PricingEngine behind ports (Ch.7), an EventBus (Ch.6), OrderService/UserService/EmailService (Ch.4–6), and a stateless quote API (Ch.8) — and runs one real order through the whole thing end to end.
Wiring the Full System
Note the one deliberate design choice this capstone adds on top of the individual chapters: OrderService and the quote API both hold a reference to the identical PricingEngine instance — not two separately-constructed ones.
Running One Real Order End to End
quote_price_via_api() before buying — 103.5. They then place the order through order_service.place_order() — the actual charge is 103.5. Identical, matching a manual calculation (100 × 1.15 scarcity, stock 5 < 10, × 0.9 loyalty) exactly. This is Chapter 8's own thick-client lesson, applied a second time on the server side: had the quote endpoint and the order-placement flow used two separately-written pricing implementations instead of one shared PricingEngine, they could have silently diverged the same way the mobile and web clients did.
notification_adapter.notifications_sent shows ['PROD-1', 'PROD-1'] — two low-stock notifications, one from the quote call and one from the actual order. Both calls independently triggered PricingEngine's own scarcity-pricing branch, confirming the quote and the real order didn't just happen to agree — they ran through the exact same decision logic, twice.
user_service.users correctly shows {'USER-1': {'points': 10}}; email_service.sent_emails correctly shows the confirmation message with the right total. OrderService's own source, re-checked here exactly as in Chapter 6, still contains False references to both UserService and EmailService by name. repository.get('ORD-1') correctly stored final_price: 103.5, matching the price actually charged.
A Real ADR for This Design
PricingEngine as a single box, connected to both the order-processing flow and (indirectly, via the Pricing API) the client layer. This capstone's own verified code confirms that diagram wasn't aspirational — it's an accurate picture of what actually got built.
What This Course Doesn't Cover
This course deliberately stopped at a single application's own internal shape. It did not cover how OrderService and UserService would actually be deployed as separate, independently-scalable processes (Chapter 4 measured the network cost of that decision, but didn't build a real deployed system); how the event bus would behave under real concurrent load; or any of the resilience patterns (circuit breakers, retries, rate limiting) a genuinely production system would need once EventBus becomes a real message queue instead of an in-process Python object.
Where This Connects
| This capstone's finding | What it connects to |
|---|---|
| Every component from Chapters 1–9 assembled into one verified, running system | Confirms this course's own chapter-to-chapter continuity wasn't just narrative — the pieces genuinely compose |
| The scope note above (deployment, real message queues, resilience patterns) | Distributed Systems & Scalability — this course's own direct sibling, picking up exactly where this scope note leaves off |
| A real ADR, tested against Chapter 9's own four-question standard | This capstone's own ADR intentionally follows Chapter 9's Context/Decision/Consequences shape, not a shortcut version |
Hands-On Exercises
Run this chapter's own capstone scenario a second time with stock_data = {'PROD-1': 50} (well-stocked, no scarcity pricing) for a non-loyalty customer. Verify the quoted price still matches the actual charged price, and that no low-stock notification fires this time.
Deliberately break this chapter's own guarantee: construct a second, separate PricingEngine instance for the quote API only, while OrderService keeps using the original. Using different stock data for each engine's own adapter, verify the quoted price and actual charged price now genuinely diverge — reproducing Chapter 8's own thick-client bug at the server level.
Using this chapter's own ADR and Chapter 9's own four-question test, verify how many of those four questions this chapter's ADR-007 can actually answer. Identify which section of ADR-007 answers each one.
📄 View solutionChapter 10 Quick Reference — and Course Quick Reference
- This capstone: every verified component from Chapters 1–9 assembled into one working system — a customer's quoted price (
103.5) verified matching the actual charged price exactly, because both paths share onePricingEnginecore - The one new design decision: sharing a single core instance between the quote API and the order-placement flow, documented in a real ADR that passes Chapter 9's own four-question test
- Course arc: Ch.1 (measuring architecture's cost) → Ch.2 (layers) → Ch.3 (MVC variants) → Ch.4 (monolith vs. microservices, measured) → Ch.5 (finding boundaries) → Ch.6 (event-driven decoupling) → Ch.7 (hexagonal architecture, measured) → Ch.8 (stateless APIs, thin clients) → Ch.9 (documenting it all) → Ch.10 (assembling it into one system)
- Where this leads: Distributed Systems & Scalability — this course's own sibling, covering what happens once this system needs to run across multiple machines under real load