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

LayerOwnsShould never contain
PresentationFormatting output, accepting inputBusiness rules (discount math, validation logic)
BusinessThe rules — discounts, validation, calculationsStorage details (file formats, SQL, an ORM's own API)
DataReading and writing storage, exactly as it's askedBusiness rules (a discount, a validity check)

Reusing Chapter 1's own order-processing example, extended into all three layers:

# --- Data layer: stores exactly what it's given, decides nothing --- class OrderRepository: def __init__(self): self._orders = {} def save(self, order_id, items_total): self._orders[order_id] = items_total def get(self, order_id): return self._orders[order_id] # --- Business layer: owns the rules, delegates storage to the layer below it --- class OrderService: def __init__(self, repository): self.repository = repository def place_order(self, order_id, items_total): self.repository.save(order_id, items_total) def get_order_total(self, order_id, is_loyalty_member): raw_total = self.repository.get(order_id) if is_loyalty_member: return raw_total * 0.9 # the business rule lives HERE return raw_total # --- Presentation layer: formats output, talks only to the business layer --- def display_order_total_correct(service, order_id, is_loyalty_member): total = service.get_order_total(order_id, is_loyalty_member) return f'Your total: ${total:.2f}'

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.

# --- Presentation layer: VIOLATION — skips OrderService, talks directly to the repository --- def display_order_total_broken(repository, order_id, is_loyalty_member): total = repository.get(order_id) # bypasses OrderService entirely return f'Your total: ${total:.2f}'
Verified directly — skipping the business layer produces a real, wrong total
Placing an order for 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.

class OrderRepositoryBad: def __init__(self): self._orders = {} def save(self, order_id, items_total, is_loyalty_member): self._orders[order_id] = (items_total, is_loyalty_member) def get(self, order_id): items_total, is_loyalty_member = self._orders[order_id] if is_loyalty_member: # a business rule, baked into the DATA layer itself return items_total * 0.9 return items_total def audit_raw_total(repo_bad, order_id): # an accounting function that specifically needs the RAW, undiscounted total return repo_bad.get(order_id)
Verified directly — an audit function can no longer get the real number, because the data layer already decided not to give it
Saving a raw total of 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.
Same root cause, opposite direction
Both violations above come from the same mistake: a decision that belongs to exactly one layer got made somewhere else instead. Skipping business logic from presentation loses a decision that should have been applied. Baking business logic into data applies a decision to every caller, including ones that needed the undecided version. Layering isn't about which direction is "worse" — it's about keeping each decision made in exactly one place.

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 findingWhat it connects to
One boundary (Chapter 1) generalized into three named layersChapter 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 crashTechnical 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 valueChapter 5's service-boundary criteria (coupling and cohesion) — this leak is a concrete instance of low cohesion inside the data layer

Hands-On Exercises

Exercise 1

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.

📄 View solution
Exercise 2

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.

📄 View solution
Exercise 3

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.

📄 View solution

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.00 instead 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 got 90.0 instead, with no way to ask for the real number
  • Next chapter: MVC and Its Variants — the presentation-side version of this same layering question