Software Architecture: from Script to Package
Package layout, validated inputs, and the design patterns that let a rating engine change without breaking: refactoring a monolithic pricing script into a clean Python package.
Lesson 1 · OverviewBy the end of this lesson you can…
- Lay out a project as a package:
src/layout,pyproject.toml, imports - Use type hints to say what a function accepts and returns
- Validate every input record with
pydantic, and report what is wrong - Keep the rating manual as data, not code
- Apply the Strategy and Factory patterns to rating algorithms
- Refactor safely, proving with a golden master that nothing changed by accident
Companion notebook: lesson-01-software-architecture.ipynb — worked examples and self-checking exercises.
From Script to Package
What a working script is missing.
The starting pointprice_book.py: 116 lines that work
LIAB_BASE = 938.09
style_factors = {"Sedan": 1.0, "SUV": 1.05, ...}
for row in reader:
age = int(float(row["driver_age"]))
style = row["vehicle_style"]
miles = float(row["annual_mileage"])
# age factor
if age <= 25:
af = 1.6
elif age < 30:
af = 1.15
...
sf = style_factors[style]
liab = round(LIAB_BASE * sf * af * mf, 2)
...
row["change"] = round(new_prem / old_prem - 1, 4)
It reprices the 2025 book: 18,249 vehicles, $27,268,008.65 of manual premium. The author has used it for a year.
$ python price_book.py quote_requests.csv q.csv
Traceback (most recent call last):
...
KeyError: 'liab_premium'
Pricing, file handling, validation, and the renewal report are one loop. It cannot quote a vehicle that has no current premium, which is every new-business quote.
DiagnosisWhat makes a script hard to trust
Symptom in price_book.py | Why it matters | This lesson’s fix |
|---|---|---|
Factors typed into if chains | Changing a rate means editing code; nobody can review the manual | Manual as a TOML file, validated on load |
| No input checks | “sedan”, −13,600 miles, and age 3 are all priced (or crash mid-file) | pydantic models |
| One loop does everything | Can’t reuse the rating for quotes, an API, or a dashboard | Modules with one job each |
| One algorithm per coverage, hard-wired | A filed amendment means a copy of the script | Strategy + Factory |
| No tests | Two boundary bugs have been charging customers for a year | Golden master now; full tests in Lesson 2 |
LayoutThe src/ layout
ratingkit/
├── pyproject.toml # name, dependencies, tools
├── uv.lock # exact versions, committed
├── README.md
├── src/
│ └── ratingkit/
│ ├── __init__.py # the public API
│ ├── schemas.py # validated inputs
│ ├── manual.py # the rating manual
│ ├── manuals/motor_2025.toml
│ ├── algorithms.py # one rater per coverage
│ ├── factory.py # raters chosen by name
│ ├── engine.py # RatingEngine, Quote
│ ├── book.py # files in, files out
│ └── cli.py # the ratingkit command
└── tests/
- Code lives under
src/, so tests import the installed package, never a stray file in the current folder - One module, one responsibility: the rating logic never opens a file
- The manual ships inside the package: code and rates are versioned together
cli.pyis the only place that knows about the command line
Course 2 and 3 capstones already used this layout. This lesson explains why each piece is there.
pyproject.tomlOne file describes the project
[project]
name = "ratingkit"
version = "1.0.0"
requires-python = ">=3.12"
dependencies = ["pandas>=2.2", "pydantic>=2.9"]
[project.scripts]
ratingkit = "ratingkit.cli:main" # a command
[build-system]
requires = ["uv_build>=0.11,<0.12"]
build-backend = "uv_build"
[dependency-groups]
dev = ["pytest>=8", "hypothesis>=6.100",
"ruff>=0.8", "mypy>=1.13"]
$ uv sync # .venv + locked versions
$ uv run ratingkit book_2025.csv priced.csv
18,249 vehicles priced, 0 rejected
$ uv run ratingkit quote_requests.csv q.csv \
--id-column quote_id
963 vehicles priced, 37 rejected
dependencies: what users need; thedevgroup: what developers need[project.scripts]turns a function into a commanduv.lockpins every version, so every machine gets the same environment
ImportsA package has a front door
# src/ratingkit/__init__.py
"""ratingkit: the motor rating manual as a tested,
typed Python package."""
from ratingkit.engine import Quote, RatingEngine
from ratingkit.manual import RatingManual, load_manual
from ratingkit.schemas import Vehicle
__all__ = ["Quote", "RatingEngine", "RatingManual",
"Vehicle", "load_manual"]
# a user of the package
from ratingkit import Vehicle, load_manual
from ratingkit.factory import build_engine
engine = build_engine(load_manual())
- Absolute imports (
from ratingkit.engine import …) work the same from tests, scripts, and notebooks __all__is the public API: everything else may change- Modules import in one direction:
schemas←manual←algorithms←factory. A cycle means two modules should be one, or a third is missing
Types & Validation
Say what a value may be, then enforce it.
Type hintsDocumentation the computer can check
from typing import Literal, Protocol
Coverage = Literal["LIAB", "COLL", "COMP"]
Style = Literal["Sedan", "SUV", "Hatchback",
"Pickup", "Minivan", "Sports"]
def earned(total: float, exposure: float) -> float: ...
coll_deductible: int | None = None # optional
class CoverageRater(Protocol): # any object with
def premium(self, vehicle: Vehicle) -> float: ...
| Hint | Means |
|---|---|
float, str, bool | that type |
int | None | an int, or nothing |
Literal["LIAB", …] | exactly one of these values |
dict[Coverage, float] | coverage codes to premiums |
Protocol | anything with these methods |
Python ignores hints at run time. mypy checks them before the code runs (Lesson 2); pydantic enforces them on data as it arrives.
pydanticA record that reaches the engine is ratable
from pydantic import BaseModel, ConfigDict, Field, model_validator
class Vehicle(BaseModel):
model_config = ConfigDict(frozen=True, extra="ignore")
driver_age: float = Field(ge=16, le=110)
vehicle_style: Style
vehicle_value: float = Field(gt=0, le=500_000)
annual_mileage: float = Field(gt=0, le=150_000)
urban: bool
coverages: Annotated[frozenset[Coverage],
BeforeValidator(_split)] # "LIAB|COLL"
coll_deductible: Deductible | None = None
comp_deductible: Deductible | None = None
@model_validator(mode="after")
def _coverages_consistent(self) -> Self:
if "LIAB" not in self.coverages:
raise ValueError("liability (LIAB) is compulsory")
... # a deductible if and only if the cover is bought
return self
- Field rules: ranges (
ge,gt,le), allowed values (Literal) - Record rules:
model_validatorsees all the fields at once - Text from files is converted:
"40"→ 40.0,"False"→ False frozen=True: a validated vehicle can’t be changed afterwards into an invalid one
Vehicle.model_validate({"driver_age": "3", ...})
# ValidationError: driver_age
# Input should be greater than or equal to 16
Result1,000 new-business quote requests
| Field | Errors | Example |
|---|---|---|
driver_age | 10 | 3.0; blank |
vehicle_style | 7 | “sedan”, “Coupe”, “SUV␣” |
annual_mileage | 5 | −13,600 |
coll_deductible | 5 | 2,000; 750 |
| (record) | 4 | COLL deductible, no COLL |
vehicle_value | 4 | 0 |
urban | 2 | “city”, “maybe” |
| Rejected | 37 | 963 priced |
Every rejected row comes back with its field and the reason, so it can be fixed at the source rather than guessed at.
38 errors were planted; 37 were caught. One row says urban = "Y", and pydantic’s default (lax) mode reads “Y”, “yes” and “1” as True. That may be exactly what you want, or not: ConfigDict(strict=True) accepts only real booleans. Decide deliberately.
GotchasFiles are text
# CSV gives "500"; Literal[250, 500, 1000] wants an int
def _whole_number(value):
if isinstance(value, str) and value.replace(".", "", 1).isdigit() \
and float(value).is_integer():
return int(float(value))
return value
Deductible = Annotated[Literal[250, 500, 1000],
BeforeValidator(_whole_number)]
raw = pd.read_csv(path, dtype=str, keep_default_na=False)
clean = {k: (None if v == "" else v) for k, v in row.items()}
- Without the converter, every deductible in the file was rejected: pydantic won’t turn the text “500” into the number 500 to match a
Literal - Let pandas guess, and a blank age becomes
NaN: a valid float that compares false with everything. Read text, and make blanksNone, which a required field rejects - A
BeforeValidatorruns before the type check, anAfterValidatorafter
Design Patterns for Rating
The manual as data; algorithms that can be swapped.
ConfigurationThe rating manual is data
# motor_2025.toml
[base_rate] # annual premium, base risk
LIAB = 938.09
COLL = 576.85
COMP = 158.35
[style] # LIAB and COLL
Sedan = 1.00
Sports = 1.40 # ...
[driver_age] # bands are [lower, upper)
upper = [25, 30, 65, 75, 200]
factor = [1.60, 1.15, 1.00, 1.10, 1.35]
[deductible] # COLL and COMP
250 = 1.10
500 = 1.00
1000 = 0.88
class BandTable(BaseModel):
upper: tuple[float, ...]
factor: tuple[float, ...]
@model_validator(mode="after")
def _check(self) -> Self: # bounds must increase
...
def lookup(self, value: float) -> float:
for upper, factor in zip(self.upper, self.factor,
strict=True):
if value < upper:
return factor
raise ValueError(f"{value} is above the last band")
A rate change is now a reviewed edit to one file, validated when loaded: bounds out of order, or a missing factor, stop the engine before it prices anything.
Strategy patternOne algorithm per coverage, same shape
class CoverageRater(Protocol):
@property
def coverage(self) -> Coverage: ...
def premium(self, vehicle: Vehicle) -> float: ...
@dataclass(frozen=True)
class ManualLiability:
manual: RatingManual
coverage: Coverage = "LIAB"
def premium(self, v: Vehicle) -> float:
m = self.manual
return money(m.base_rate["LIAB"] * m.style[v.vehicle_style]
* m.driver_age.lookup(v.driver_age)
* m.annual_mileage.lookup(v.annual_mileage))
@dataclass(frozen=True)
class UrbanAmendedLiability: # the Course 3 rate review
manual: RatingManual
urban_factor: float = 1.25
non_urban_factor: float = 0.857
...
The engine asks each rater for a premium and never asks which kind of rater it is. A new algorithm, whether a filed amendment or a GLM (Lesson 4), is a new class, not an edit to the engine.
A Protocol describes the shape; classes don’t inherit from it. mypy checks that each rater fits.
Factory patternAlgorithms chosen by name
ALGORITHMS = {
("LIAB", "manual"): ManualLiability,
("LIAB", "urban_amendment"): UrbanAmendedLiability,
("COLL", "manual"): ManualCollision,
("COMP", "manual"): ManualComprehensive,
}
def build_engine(manual, choices=None) -> RatingEngine:
names = {**DEFAULT, **(choices or {})}
raters = {}
for coverage, name in names.items():
try:
make = ALGORITHMS[(coverage, name)]
except KeyError:
raise ValueError(f"no {coverage} algorithm "
f"called {name!r}") from None
raters[coverage] = make(manual)
return RatingEngine(raters)
$ uv run ratingkit book_2025.csv amended.csv \
--liability urban_amendment
| 2025 book, LIAB | Vehicles | Change |
|---|---|---|
| Urban (× 1.25) | 10,902 | +25.0% |
| Non-urban (× 0.857) | 7,347 | −14.3% |
| All liability | 18,249 | +9.18% |
| All coverages | +6.71% |
The configuration names the algorithm; the factory builds it. Unknown names fail at start-up with the list of valid ones.
Refactoring safelyThe golden master: price the book both ways
| 18,249 vehicles | Count | Premium effect |
|---|---|---|
| Identical | 17,854 | |
| Driver aged 25.x | 254 | −$151,078.71 |
| Exactly 15,000 miles | 72 | +$20,464.48 |
| One cent (rounding) | 71 | +$0.71 |
Two vehicles are in both bug groups. The monolith charged $129,999.63 more than its own manual.
age = int(float(row["driver_age"])) # 25.7 -> 25
if age <= 25: # manual: under 25
af = 1.6
...
elif miles <= 15000: # manual: under 15,000
mf = 1.0
VEH000130: a 25.7-year-old in a Hatchback paid the under-25 factor, LIAB $1,981.25 instead of $1,424.02. Refactoring means same behavior; every difference must be explained, and here each one traces to a bug in the old script.
MoneyRound the way the manual says
>>> round(836.645, 2)
836.64 # 836.645 is stored as 836.64499999...
from decimal import ROUND_HALF_UP, Decimal
def money(amount: float) -> float:
"""Round half up, to the cent."""
return float(Decimal(repr(amount))
.quantize(Decimal("0.01"),
rounding=ROUND_HALF_UP))
>>> money(836.645)
836.65
The 71 one-cent differences are premiums that end in exactly half a cent. round() works on the binary float, which is a hair below the half; money() rounds the decimal number the user sees.
A cent matters when a regulator asks you to reproduce a filed premium. Write the rounding rule down, put it in one function, and test it (Lesson 2).
Actuarial Lab · 75 minRefactor the monolith
- Run
starter/monolith/price_book.pyon the book; save its output as the golden master - Create the package with
uv init --packagein asrc/layout - Write
Vehiclewith every field and record rule; validate the quote requests - Move the factors into a TOML manual; load it into validated band tables
- One rater class per coverage; a
RatingEnginethat combines them - A factory, and the urban amendment as a second LIAB algorithm
- A
ratingkitcommand; compare with the golden master and explain every difference
Answer key
| Valid / rejected requests | 963 / 37 |
| Quote: 22, Sports, 16,000 mi | LIAB 2,521.59 |
| … COLL ($45k, $1,000) | 2,046.76 |
| … COMP ($250) | 313.53 |
| Urban amendment, LIAB | +9.18% |
| Vehicles priced differently | 324 (+71 cents) |
RecapLesson 1 checklist
- My code is a package in a
src/layout with apyproject.toml - Each module has one job; the rating logic never touches files
- Every input is validated, and rejections say which field and why
- I know what lax mode accepts
- The rating manual is a file, validated when loaded
- Each coverage has a rater with the same shape (Strategy)
- Algorithms are chosen by name from configuration (Factory)
- I refactor against a golden master and explain every difference
Automated Testing, Code Quality & CI
pytest, test-driven development, property-based tests, ruff, mypy, and GitHub Actions: catching the boundary bugs before customers pay for them.