PYTH-290 · Lesson 3 of 12 · 25 min
Architecture and technology choices
▶ Video coming soon A video version of this lesson is on the way.
By the end of the first week the team knew what Ridgeline Ops had to do. The next question was how to build it: how to divide the code into parts, which existing pieces to reuse, which libraries and services to depend on, and where the application would run. These are architecture decisions, and they are different from everyday coding decisions in one important way: they are expensive to change later. Choosing a variable name takes seconds to undo. Choosing a database, a framework or a hosting platform can take weeks.
This lesson follows how the team made those decisions in a single afternoon workshop with Lena, how they wrote them down, and how they set up the code so that the decisions they were least sure about would be the easiest to change.
What architecture means on a small project
Architecture is sometimes imagined as something only large companies do, with diagrams on walls and architects who never write code. On a small project it is simply the set of decisions that would be costly to reverse, made early enough to matter and recorded clearly enough to be understood. For Ridgeline Ops those decisions were few: the overall shape of the application, the layers inside it, the database, the web framework, the hosting, and how the new system would connect to the existing web shop.
A useful way to find architecture decisions is to ask, for each choice: if we got this wrong, how long would it take to fix? If the answer is minutes, decide quickly and move on. If the answer is days or weeks, slow down, compare options, and write the decision down. Most of a project's choices fall in the first group, and treating them as architecture wastes time.
The team also agreed on the quality attributes that would drive the architecture, taken from the requirements in lesson 2: correctness of money, privacy of subscriber data, low running cost, a restore time of four hours, and a codebase that a team of three students and a part-time coach could understand. That last attribute, often called maintainability or simplicity, ruled out several fashionable options before anyone scored them.
Monolith or services
The first decision was the overall shape. The team could build one application that contains every feature, often called a monolith, or several small services that talk to each other over a network, often called microservices. Microservices let large organizations deploy parts independently and scale them separately, but they add a great deal of work: network calls that can fail, data spread across databases, more deployments to manage and more things to monitor.
For a team of three with five weeks, the answer was clear, and PYTH-270 had already recorded it as decision 0001: a modular monolith. Ridgeline Ops is one Python application deployed as one unit, but its code is divided into modules with clear boundaries, subscriptions, coffees and stock, orders, roasting and reports, that talk to each other only through small, deliberate interfaces. If Ridgeline ever grows enough to need a separate service, a well-bounded module can be extracted later. Starting with services you do not need is far harder to undo than splitting a monolith you have outgrown.
Layers and the dependency rule
Inside the monolith, the team reused the layered design from PYTH-270. The domain layer holds the business rules as plain Python: what a subscription is, how the next delivery date is calculated, when a skip is allowed. It knows nothing about databases or web pages. The application layer holds services that carry out use cases, such as run_renewals or skip_next_delivery, by loading domain objects through repositories, applying rules and saving the results. The adapters layer holds everything that talks to the outside world: the SQLite repositories, the web API, the command-line interface, the email sender and the link to the existing web shop.
The rule that keeps this design healthy is the dependency rule: source code dependencies point inward. Adapters may import the application and the domain; the application may import the domain; the domain imports nothing from the outer layers. When the application needs something from outside, such as storage, it defines a small interface, called a port, and an adapter implements it. A composition root at the edge of the program creates the real adapters and passes them in.
The reward comes in testing and in change. Because the domain and application layers do not import the database or the web framework, almost all of the business logic can be tested with fast, in-memory fakes. And when the team later replaced the email adapter with a different provider, not a single line of the subscription rules changed.
# Who imports whom, taken from the import statements in each module
IMPORTS = {
"ridgeline_ops.domain.subscription": ["ridgeline_ops.domain.money"],
"ridgeline_ops.domain.money": [],
"ridgeline_ops.app.renewals": ["ridgeline_ops.domain.subscription", "ridgeline_ops.app.ports"],
"ridgeline_ops.app.ports": ["ridgeline_ops.domain.subscription"],
"ridgeline_ops.adapters.sqlite_repo": ["ridgeline_ops.app.ports", "ridgeline_ops.domain.subscription"],
"ridgeline_ops.adapters.web": ["ridgeline_ops.app.renewals"],
}
def layer(module):
return module.split(".")[1] # domain, app or adapters
RANK = {"domain": 0, "app": 1, "adapters": 2}
problems = 0
for module, targets in IMPORTS.items():
for target in targets:
if RANK[layer(target)] > RANK[layer(module)]:
print(f"VIOLATION: {module} imports {target}")
problems += 1
print("dependency rule holds" if problems == 0 else f"{problems} problem(s)")dependency rule holds
The team added a version of this check to the test suite on the first day, reading the real import statements with the ast module. When Nia later added a convenience import of the SQLite adapter into a domain module to save time, the check failed in the pull request before anyone had to notice it in review.
Try it yourself
Enforce the dependency rule
Write violations(imports, order) where imports maps each module name to the list of modules it imports, and order lists the layer names from innermost to outermost, for example ["domain", "app", "adapters"]. A module's layer is the second part of its dotted name.
Return a sorted list of (module, target) pairs where the target's layer is further out than the module's layer. Imports of modules outside the project (whose first part is not the same as the module's first part) are ignored.
def violations(imports, order):
found = []
for module, targets in imports.items():
for target in targets:
if target != module:
found.append((module, target))
return found
IMPORTS = {
"ops.domain.sub": ["ops.domain.money", "datetime"],
"ops.app.renew": ["ops.domain.sub"],
"ops.domain.money": ["ops.adapters.db"],
"ops.adapters.db": ["ops.app.renew", "sqlite3"],
}
print(violations(IMPORTS, ["domain", "app", "adapters"]))Choosing technologies
With the shape decided, the team chose the main technologies. They used the same weighted-matrix idea as in lesson 1, with one addition: constraints. A constraint is a requirement that an option must meet to be considered at all, such as keeping data in the country or fitting the budget. Options that fail a constraint are removed before scoring, because no amount of convenience makes up for breaking a hard requirement.
For the database, the realistic options were SQLite, which the team had used in PYTH-215 and which stores the whole database in one file, and a managed PostgreSQL service like the one designed for the wholesale site in NET-120. PostgreSQL handles many simultaneous writers and offers built-in point-in-time recovery, but costs more each month and adds a network hop. SQLite is free, fast and simple, and handles a small business's load easily, but needs care with backups and allows only one writer at a time.
Both passed the constraints. The scores favoured SQLite for this term, mainly on cost and simplicity, with a continuous backup copied to cloud storage in the country to meet the fifteen-minute data-loss limit. Because every database access goes through a repository port, moving to PostgreSQL later would mean writing one new adapter, not rewriting the application. The team chose the option that was cheapest now and made the expensive alternative cheap to reach.
For the web layer, the team chose Flask, which everyone knew from PYTH-220, with server-rendered pages for staff and a small JSON API for the web shop and a future subscriber page. Django was a strong alternative with its admin site and built-in accounts, and the team discussed it seriously, but it would have pulled the application toward Django's own structure for models and data access, cutting across the repository design they wanted to keep. FastAPI was attractive for the API but added asynchronous code nobody needed. For hosting, the team reused the cloud account set up for the wholesale site in NET-120: a small container service in a Canadian region for the Canadian business, with storage for backups in a second region in the same country.
CONSTRAINTS = {"data_in_country": True, "monthly_cost_max": 50}
WEIGHTS = {"simplicity": 0.35, "team_knows_it": 0.25, "reliability": 0.25, "growth": 0.15}
OPTIONS = {
"SQLite + continuous backup": {"data_in_country": True, "monthly_cost": 3,
"simplicity": 5, "team_knows_it": 5, "reliability": 4, "growth": 2},
"Managed PostgreSQL": {"data_in_country": True, "monthly_cost": 35,
"simplicity": 3, "team_knows_it": 3, "reliability": 5, "growth": 5},
"Free document database (overseas region)": {"data_in_country": False, "monthly_cost": 0,
"simplicity": 4, "team_knows_it": 2, "reliability": 4, "growth": 4},
}
def allowed(o):
return o["data_in_country"] and o["monthly_cost"] <= CONSTRAINTS["monthly_cost_max"]
for name, o in OPTIONS.items():
if not allowed(o):
print(f"{'out':>5} {name}")
continue
score = sum(WEIGHTS[k] * o[k] for k in WEIGHTS)
print(f"{score:5.2f} {name}")4.30 SQLite + continuous backup 3.80 Managed PostgreSQL out Free document database (overseas region)
Try it yourself
Choose with constraints
Write choose(options, constraints, weights). options maps each name to a dictionary of facts and scores. constraints maps a fact name to a function that takes the fact's value and returns True if it is acceptable. Remove every option that fails any constraint, score the rest with the weights (rounded to 2 places), and return a list of (score, name) tuples from best to worst, ties in alphabetical order.
If every option fails a constraint, return an empty list.
def choose(options, constraints, weights):
results = []
for name, facts in options.items():
score = sum(facts[k] for k in weights)
results.append((score, name))
return sorted(results)
OPTIONS = {
"SQLite": {"cost": 3, "in_country": True, "simple": 5, "growth": 2},
"PostgreSQL": {"cost": 35, "in_country": True, "simple": 3, "growth": 5},
"Overseas DB": {"cost": 0, "in_country": False, "simple": 4, "growth": 4},
}
CONSTRAINTS = {"cost": lambda c: c <= 50, "in_country": lambda v: v}
WEIGHTS = {"simple": 0.6, "growth": 0.4}
print(choose(OPTIONS, CONSTRAINTS, WEIGHTS))Recording decisions
A decision that lives only in people's memories is soon forgotten or misremembered. PYTH-270 introduced architecture decision records, short documents that capture one decision each, and the capstone team kept using them. Each record is a small Markdown file in the repository's docs folder, numbered in order, with a title, the date, a status (proposed, accepted, superseded), the context that forced the decision, the decision itself, the alternatives considered and the consequences, good and bad.
Ridgeline Ops started with three new records added to the eight carried over from PYTH-270: 0009 use SQLite with continuous backup for the first release; 0010 use Flask for the staff pages and the JSON API; 0011 the new system owns subscriptions, and the existing web shop remains the system of record for one-off online orders, with renewal orders sent to the shop through its API so that Anita has one source of sales. That last decision, about which system owns which data, avoided weeks of confusion later. When two systems both believe they own the same data, every bug report starts with an argument about which copy is right.
Records are written once and then left alone. If a decision changes, a new record supersedes the old one and both stay in the folder. That history is valuable: six months later, when someone asks why the project uses SQLite, the record explains that it was a deliberate choice for cost and simplicity, with a known path to PostgreSQL, not an accident.
Reuse, build or buy
For every part of the system, the team asked whether to reuse code that already existed, build something new, or use a library or service someone else maintains. Reuse came first. The Money value object, the rounding rules, the repository pattern and the service layer from PYTH-270 moved into Ridgeline Ops almost unchanged, with their tests. The renewal scheduling was new and had to be built, because it was the heart of the project and full of Ridgeline's own rules.
For general problems, the team preferred well-known libraries over code of their own: password hashing from the standard library, templates from Jinja through Flask, date handling from datetime, and email through a commercial sending service rather than running a mail server. Writing your own versions of solved problems is a classic way for capstone projects to run out of time. The test for adding a dependency was short: is it widely used and maintained, is its licence compatible with Ridgeline's use, does it solve a problem we would otherwise spend days on, and could we replace it if we had to?
A good rule of thumb is to build what makes your project different and buy or borrow everything else. For Ridgeline, the difference is the subscription rules, the roast planning and the reports. Nobody will choose Ridgeline because it wrote its own login system.
Spikes for risky unknowns
Two decisions had real uncertainty: whether the existing web shop's API could accept renewal orders created by another system, and whether the continuous backup tool worked on the chosen hosting service. Rather than argue, the team ran spikes: short, time-boxed experiments to answer one question, written as throwaway code. Sam spent half a day on each.
The shop spike found that its API accepted orders but would charge a card only through its own checkout page, which confirmed the decision to keep card payments out of scope and have staff charge subscribers through the shop. The backup spike worked first time. Spikes are cheap insurance: half a day of experiment can prevent a week of building on an assumption that turns out to be false. The rule is that spike code is thrown away, and the lesson learned is written down, often as the context of a decision record.
Common mistakes
MistakeTreating every technical choice as an architecture decision.
InsteadAsk how long a wrong choice would take to fix; spend time only on the expensive ones.
MistakeStarting with microservices because large companies use them.
InsteadStart with a modular monolith with clear boundaries; extract services only when a real need appears.
MistakeLetting domain code import the database or web framework for convenience.
InsteadKeep dependencies pointing inward and check the rule automatically in the test suite.
MistakeScoring options that break a hard requirement.
InsteadApply constraints first and score only the options that pass.
MistakeKeeping decisions in chat messages and memory.
InsteadWrite an architecture decision record for each significant decision and keep it in the repository.
MistakeBuilding your own versions of solved problems such as login or email delivery.
InsteadBuild what makes the project different and use maintained libraries and services for the rest.
Summary and key terms
- Architecture is the set of decisions that would be expensive to reverse; decide cheap ones quickly.
- A modular monolith suits a small team; services can be extracted later if needed.
- Layers keep business rules independent: domain, application services, and adapters at the edge.
- The dependency rule says source dependencies point inward; check it automatically.
- Constraints remove unacceptable options before weighted scoring compares the rest.
- Choosing the cheapest option now is safe when the design keeps the expensive alternative easy to reach.
- Architecture decision records capture context, decision, alternatives and consequences, and are superseded, not edited.
- Spikes are short experiments that answer one risky question before the team builds on it.
- architecture
- The significant design decisions about a system's structure that are costly to change.
- monolith
- An application built and deployed as a single unit.
- modular monolith
- A monolith divided internally into well-bounded modules with deliberate interfaces.
- microservices
- An architecture of small, independently deployed services that communicate over a network.
- dependency rule
- In a layered design, source code dependencies point only toward the inner layers.
- port
- An interface defined by the application for something it needs from the outside world.
- adapter
- Code that implements a port using a specific technology, such as SQLite or a web framework.
- constraint
- A requirement an option must meet to be considered at all.
- architecture decision record
- A short document recording one significant decision, its context and its consequences.
- spike
- A short, time-boxed experiment to answer a technical question, with throwaway code.
Practice problems
Work each problem on paper first, then reveal the answer to check.
Give one question that tells you whether a choice is an architecture decision.
Show answer
If we got this wrong, how long would it take to fix? Days or weeks suggests an architecture decision.
Why did the team choose a modular monolith instead of microservices?
Show answer
A small team with a short deadline gains little from independent services and would pay for the extra network, deployment and monitoring work.
An application service imports the Flask request object. Which rule does this break and why does it matter?
Show answer
The dependency rule: the application layer now depends on an adapter, so it cannot be tested or reused without the web framework.
Why are constraints applied before scoring?
Show answer
An option that breaks a hard requirement is unacceptable no matter how well it scores on other criteria.
What should happen to an architecture decision record when the decision changes?
Show answer
A new record supersedes it; the old record stays, marked superseded, so the history is kept.
What is the rule about code written during a spike?
Show answer
It is thrown away; the lesson learned is written down, often in a decision record.
Lesson quiz · 5 questions
Score 60% or more to complete this lesson.
Create a free account to save your progress, take quizzes and earn a certificate.