PYTH-270 · Lesson 8 of 12 · 26 min

Layered and modular architecture

▶ Video coming soon A video version of this lesson is on the way.

So far this course has worked at the scale of functions, classes and small groups of objects. Architecture is design at the scale of the whole application: how it is divided into large parts, what each part may know about the others, and where the boundaries with the outside world, such as databases, payment providers and user interfaces, sit. Architectural decisions are the expensive ones to change later, which is why they deserve deliberate thought.

Ridgeline's code shows what happens without them. The web shop's request handlers run SQL directly, the pricing rules import the email module, the command-line tool and the web shop each have their own copy of the checkout steps, and the tests need a real database because business logic and storage are mixed together. This lesson introduces layered architecture, the dependency rule, ports and adapters, the repository and service layer patterns, and the practical side: package layout, import rules and wiring everything together in one place.

Layers and the dependency rule

A layered architecture divides an application into horizontal layers, each with one kind of responsibility. A common version has four. The presentation layer handles interaction: web pages, an API, a command-line interface. The application or service layer coordinates use cases such as place an order or renew a subscription. The domain layer holds the business concepts and rules: Order, Money, pricing, stock rules. The infrastructure layer deals with technical details: the database, email, payment providers, files.

The rule that makes layers useful is about direction: dependencies point inward, toward the domain. Presentation may use the application layer, the application layer may use the domain, and nothing in the domain imports from the web framework, the database library or the email client. The domain is the part of the program most worth protecting, because it contains the knowledge that makes Ridgeline Ridgeline, and it is also the part that should change only when the business changes, not when a library or a screen design changes.

Four concentric rings. The innermost is the domain with Order, Money and pricing rules. Around it is the application layer with use cases such as place order. Around that are two outer segments: presentation with the web shop, kiosk and CLI, and infrastructure with SQLite, email and payment adapters. Arrows from the outer rings point inward only.
Dependencies point inward: the domain knows nothing about frameworks, databases or screens

How can the application layer save an order if it may not import the database code? Through the dependency inversion principle from lesson 2: the inner layer defines what it needs, as a protocol such as OrderRepository with add and get, and the outer layer provides a class that satisfies it. The arrow of the source-code dependency points inward, even though at run time the inner code calls the outer code.

Ports and adapters

Alistair Cockburn's hexagonal architecture, also called ports and adapters, states the same idea from a different angle. The application sits in the middle. Each way it talks to the outside world is a port: a protocol defined by the application, such as PaymentPort, OrderRepository or Notifier. Each technology that plugs into a port is an adapter: a LumenPay adapter and a Northwind adapter for PaymentPort, a SQLite adapter and an in-memory adapter for OrderRepository, an email adapter and a text-message adapter for Notifier. The adapters of lesson 5 are exactly these.

Driving adapters, such as the web shop and the command-line tool, call into the application; driven adapters, such as the database and the payment provider, are called by it. Because every adapter depends on a port and not the other way round, you can run the whole application with in-memory adapters in tests, or swap SQLite for PostgreSQL by writing one new adapter.

A hexagon labeled Ridgeline application with ports on its edges. On the left, driving adapters for the web shop, kiosk and command-line tool point into the hexagon. On the right, the hexagon points out to driven adapters: SQLite repository, in-memory repository, LumenPay and Northwind payment adapters, and an email notifier.
Ports and adapters: the application defines ports; technologies plug in as adapters

Repositories: collections that hide storage

The repository pattern, described by Martin Fowler and Eric Evans, gives the domain the illusion of an in-memory collection of objects. Code asks the order repository to add an order or get order 1041 and receives Order objects; how they are stored, as rows in SQLite, documents in a cloud database or entries in a dictionary, is the repository's business. The domain never sees SQL, and the query details live in one class per kind of object.

A repository port and an in-memory adapter
from dataclasses import dataclass, field
from typing import Protocol

@dataclass
class Order:
    id: int
    customer: str
    lines: list = field(default_factory=list)
    status: str = "new"

class OrderRepository(Protocol):          # the port, defined by the application
    def add(self, order: Order) -> None: ...
    def get(self, order_id: int) -> Order: ...
    def list(self) -> list: ...

class InMemoryOrders:                     # an adapter, used in tests and demos
    def __init__(self):
        self._rows = {}

    def add(self, order):
        self._rows[order.id] = order

    def get(self, order_id):
        return self._rows[order_id]

    def list(self):
        return sorted(self._rows.values(), key=lambda o: o.id)

repo: OrderRepository = InMemoryOrders()
repo.add(Order(1041, "Alder Hotel", [("RC-101", 12)]))
repo.add(Order(1040, "Lakeview Cafe", [("RC-104", 6)]))
print([o.id for o in repo.list()])
print(repo.get(1041).customer)
Output
[1040, 1041]
Alder Hotel

The SQLite version, written in PYTH-215, has the same three methods; the in-memory version makes tests run in milliseconds. A repository should speak the language of the domain, so add methods such as orders_for(customer) or due_for_renewal(on_date) when the application needs them, rather than exposing a general query method that leaks SQL into the application layer.

Try it yourself

An in-memory coffee repository

Write InMemoryCoffees with methods add(coffee), get(sku), low_stock(limit) and list(). Coffees are dictionaries with keys sku, name and stock.

get raises KeyError for an unknown SKU, list returns coffees sorted by SKU, and low_stock(limit) returns the SKUs of coffees whose stock is below limit, sorted. add with an existing SKU replaces the stored coffee.

class InMemoryCoffees:
    def __init__(self):
        self.items = []

    def add(self, coffee):
        self.items.append(coffee)

    def list(self):
        return self.items

The service layer

The application layer is often built as a set of services, one method per use case: place_order, cancel_order, renew_subscription. Each method coordinates the domain objects and the ports: it loads what it needs from repositories, asks domain objects to apply business rules, saves the results and triggers notifications. It contains the steps of the use case but not the business rules themselves, which belong to the domain, and not the technical details, which belong to adapters. You will recognise the facade from lesson 5.

Services receive their dependencies through the constructor, so every adapter can be replaced. The clock is a dependency too: a service that calls datetime.now() directly cannot be tested for what happens at midnight on the last day of the month, but one that receives a clock function can.

A service coordinates a use case through injected ports, including the clock
from datetime import datetime

class InMemoryOrders:
    def __init__(self):
        self.rows = []
    def add(self, order):
        self.rows.append(order)

class ListNotifier:
    def __init__(self):
        self.sent = []
    def send(self, to, text):
        self.sent.append((to, text))

class OrderService:
    def __init__(self, orders, notifier, prices, clock=datetime.now):
        self.orders, self.notifier, self.prices, self.clock = orders, notifier, prices, clock

    def place_order(self, customer, lines):
        if not lines:
            raise ValueError("an order needs at least one line")
        total = round(sum(self.prices[sku] * qty for sku, qty in lines), 2)
        order = {"customer": customer, "lines": lines, "total": total, "placed": self.clock().isoformat()}
        self.orders.add(order)
        self.notifier.send(customer, f"Thanks! Your order total is {total:.2f}")
        return order

fixed = lambda: datetime(2026, 11, 10, 9, 30)
service = OrderService(InMemoryOrders(), ListNotifier(), {"RC-101": 18.50, "RC-104": 20.25}, clock=fixed)
print(service.place_order("mei@example.com", [("RC-101", 2), ("RC-104", 1)]))
print(service.notifier.sent)
Output
{'customer': 'mei@example.com', 'lines': [('RC-101', 2), ('RC-104', 1)], 'total': 57.25, 'placed': '2026-11-10T09:30:00'}
[('mei@example.com', 'Thanks! Your order total is 57.25')]

Try it yourself

A renewal service

Write RenewalService(subscriptions, notifier, today) where subscriptions is a list of dictionaries with keys customer, next (a date) and active (a bool), notifier has a send(customer, text) method, and today is a function returning the current date.

Its method renew_due() finds active subscriptions whose next date is on or before today(), moves each one's next date forward by 14 days, sends the customer the text Renewed until YYYY-MM-DD with the new date, and returns the number renewed.

from datetime import date, timedelta

class RenewalService:
    def __init__(self, subscriptions, notifier, today):
        self.subscriptions = subscriptions

    def renew_due(self):
        count = 0
        for s in self.subscriptions:
            if s["next"] <= date.today():
                s["next"] += timedelta(days=14)
                count += 1
        return count

The composition root

If every class receives its dependencies, something must create them all and connect them. That place is called the composition root: one function, usually near the program's entry point, that builds the adapters, passes them to the services and hands the services to the presentation layer. Ridgeline's web shop and command-line tool each have a small main module that reads the configuration and calls build_app, and the test suite has its own version that uses in-memory adapters.

Keeping all the wiring in one place has two benefits. The rest of the code stays free of configuration logic, and the composition root becomes a readable map of the application: which adapter fills which port. Large frameworks offer dependency injection containers that automate the wiring, but for most Python applications a plain function is clearer.

Packages, imports and boundaries

Architecture lives in the package layout as much as in diagrams. A common layout for an application like Ridgeline's is a package per layer or, as applications grow, a package per business area (orders, inventory, subscriptions), each with its own domain, services and adapters inside. Either way, the dependency rule becomes an import rule: modules in ridgeline.domain must not import from ridgeline.adapters or ridgeline.web. Tools such as import-linter can enforce these rules in continuous integration, so a violation fails the build instead of quietly eroding the design.

Circular imports, where module a imports b and b imports a, are a common symptom of muddled boundaries. They usually mean two modules share a concept that belongs in a third, lower-level module that both can import, or that a dependency points the wrong way and should be inverted with a protocol.

Try it yourself

Check the dependency rule

Layers are listed from the inside out: LAYERS = ["domain", "application", "adapters", "web"]. A module may import from its own layer or any layer further inside, never from a layer further out. Each module name starts with its layer, such as "domain.money" or "web.views".

Write violations(imports) where imports maps each module to the list of modules it imports. Return a sorted list of (module, imported) pairs that break the rule. Imports of modules outside the layers, such as "datetime", are allowed.

LAYERS = ["domain", "application", "adapters", "web"]

def violations(imports):
    bad = []
    for module, targets in imports.items():
        for target in targets:
            if target.startswith("web"):
                bad.append((module, target))
    return bad

Monolith, modular monolith and services

Architecture discussions often jump to microservices: splitting an application into many small, separately deployed services that talk over a network. Microservices solve real problems for large organisations, where many teams must deploy independently, but they add network failures, distributed data, deployment complexity and operational cost. For a company of Ridgeline's size, and for most new applications, a modular monolith is the better start: one deployable application with strong internal boundaries, such as separate packages for orders, inventory and subscriptions that communicate through clear interfaces and events.

A well-structured modular monolith keeps the option of splitting later: if subscriptions ever needs to scale separately, its package already has a clean boundary to turn into a service. A badly structured monolith cannot be split at all, and a badly structured set of microservices is the worst of both worlds, a distributed tangle. Good boundaries matter more than the deployment style.

Worked problem

Placing code in the right layer

Sam is adding subscriptions and asks where five new pieces of code should go.

Show solution
  1. The rule that a 12-month plan gets 10 off each delivery: a business rule, so the domain layer, as a pure function or a method on Subscription.
  2. The steps of renewing all due subscriptions each morning: a use case, so the application layer, in a RenewalService.
  3. Saving subscriptions in SQLite: a technical detail, so an adapter implementing the SubscriptionRepository port.
  4. The page where customers pause their subscription: presentation, in the web package, calling the service.
  5. Creating the SQLite repository and the email notifier and passing them to RenewalService: wiring, so the composition root.

Answer: Domain for the rule, application for the renewal steps, an adapter for storage, presentation for the page, and the composition root for the wiring.

Common mistakes

  • MistakeImporting database, web or email libraries into domain code.

    InsteadKeep the domain free of infrastructure; define ports and let adapters implement them.

  • MistakePutting business rules in request handlers or SQL queries.

    InsteadPut rules in the domain; handlers and adapters only translate.

  • MistakeExposing a general query method on repositories that leaks SQL upward.

    InsteadGive repositories domain-language methods such as orders_for(customer).

  • MistakeCreating dependencies inside services.

    InsteadPass them in, and wire everything in one composition root.

  • MistakeReaching for microservices before boundaries inside the monolith are clear.

    InsteadStart with a modular monolith and split only when a real need appears.

  • MistakeSolving a circular import by moving an import inside a function.

    InsteadFind the shared concept or the wrongly directed dependency and fix the boundary.

Summary and key terms

  • Architecture is design at the scale of the whole application, and its decisions are the costliest to change.
  • A layered architecture separates presentation, application, domain and infrastructure.
  • The dependency rule: source-code dependencies point inward toward the domain.
  • Ports and adapters: the application defines ports; technologies plug in as adapters, which makes them swappable.
  • Repositories present storage as a collection of domain objects with domain-language methods.
  • Service-layer methods coordinate use cases through injected ports, including the clock.
  • A composition root wires all the parts together in one place.
  • A modular monolith with clear internal boundaries is usually the best starting architecture.
architecture
The large-scale structure of an application and the rules between its parts.
layered architecture
An application divided into layers with distinct responsibilities.
domain layer
The part of an application that holds business concepts and rules.
dependency rule
Source-code dependencies must point inward, toward the domain.
port
An interface defined by the application for talking to the outside world.
adapter (architecture)
An implementation that connects a port to a specific technology.
repository
An object that presents stored domain objects as an in-memory collection.
service layer
Application code that coordinates use cases.
composition root
The single place where an application's objects are created and connected.
circular import
Two or more modules that import each other.
modular monolith
One deployable application divided into strongly separated modules.
microservices
An architecture of small, separately deployed services communicating over a network.

Practice problems

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

  1. Name the four layers of a typical layered architecture.

    Show answer

    Presentation, application (service), domain and infrastructure.

  2. Which way do source-code dependencies point under the dependency rule?

    Show answer

    Inward, toward the domain.

  3. How can the application save orders without importing the database adapter?

    Show answer

    It defines a repository protocol (a port); the adapter implements it and is passed in.

  4. Why is the clock passed into a service?

    Show answer

    So tests can control the date and time instead of depending on the real clock.

  5. What is a composition root?

    Show answer

    The one function or module that creates the adapters and services and connects them.

  6. Why is a modular monolith often a better start than microservices?

    Show answer

    It gives clear boundaries without the network, deployment and data costs of many services, and can be split later.

Lesson quiz · 5 questions

Score 60% or more to complete this lesson.

Take the quiz