02 Code Quality in Practice: Refactoring - From Monolith to Modules
October 2025 (8495 Words, 48 Minutes)
1. Introduction: Your CI is Green, But Is Your Code Ready for Growth?
Congratulations! You’ve made it through the first major milestone of professional development:
- ✅ Chapter 02 (Code Quality in Practice): Your code follows PEP8 standards (Ruff ensures style consistency)
- ✅ Chapter 02 (Feature Branch Development): You use feature branches and pull requests (proper Git workflow)
- ✅ Chapter 02 (Automation and CI/CD): You have automated CI/CD checks (GitHub Actions validates every PR)
Your CI pipeline is green. Your code is properly formatted. Your commits are clean.
But here’s the uncomfortable truth:
Your Road Profile Viewer application lives in a single 390-line main.py file. And while that file passes all quality checks, it’s becoming a problem.
1.1 The Pain You’re About to Feel
Imagine these scenarios (you’ll likely experience them soon):
Scenario 1: The Bug Hunt
You: "There's a bug in the intersection calculation."
Also You: *Scrolls through 390 lines looking for find_intersection()*
Also You: *Passes generate_road_profile(), create_dash_app(), update_graph()...*
Also You: "Found it! Line 267. Wait, does this function depend on anything else?"
Scenario 2: The Merge Conflict
Student A: Working on improving road generation algorithm (lines 50-120)
Student B: Working on UI styling improvements (lines 200-350)
Student A: Pushes changes
Student B: Tries to push → MERGE CONFLICT
Both Students: Spend 30 minutes resolving conflicts in the same file
Scenario 3: The Testing Nightmare (Preview of Chapter 03 (Testing Fundamentals))
You: "I want to test if find_intersection() handles empty arrays correctly."
You: *Writes test*
Test: *Starts entire Dash application just to test one function*
You: "This takes 5 seconds. I have 20 functions to test..."
You: *Gives up on testing*
The core problem: All your code is tangled together. Math, UI, data generation - everything in one place.
2. Learning Objectives
By the end of this lecture, you will:
- Understand why monolithic code becomes unmaintainable as projects grow
- Learn to identify natural boundaries in your code (where to split)
- Refactor a monolithic file into focused, single-purpose modules
- Organize imports and dependencies to avoid circular dependencies
- Experience the benefits of modular code (easier navigation, testing, collaboration)
- Prepare your codebase for testing (Chapter 03 (Testing Fundamentals)) by separating concerns
What you WON’T learn (yet):
- Design patterns by name (those emerge naturally from practice)
- Advanced architecture patterns (those come later)
- Testing (that’s Chapter 03 (Testing Fundamentals) - but today’s refactoring makes it possible)
3. Applying Professional Feature Development Workflow
Before we dive into refactoring, let’s establish how we’ll approach this work. In Chapter 02 (Feature Development), you learned the professional feature development workflow:
- Create a feature branch
- Implement your changes
- Push to GitHub
- Open a Pull Request
- Automated CI checks run
- Code review
- Merge when approved
- Delete the branch
Today’s refactoring is a feature! We’ll apply this exact workflow.
3.1 Step 1: Create Your Feature Branch
Start by creating a feature branch for this refactoring work:
# Make sure you're on main and up to date
$ git checkout main
$ git pull origin main
# Create a new feature branch
$ git checkout -b feature/refactor-to-modules
Switched to a new branch 'feature/refactor-to-modules'
# Verify you're on the new branch
$ git branch
main
* feature/refactor-to-modules
Why we do this:
- ✅
mainbranch stays stable (application keeps working) - ✅ You can experiment safely
- ✅ Easy to review changes before merging
- ✅ CI will validate your refactoring
From this point forward, all your refactoring work happens on this branch.
3.2 The Plan: Iterative Refactoring with Commits
We’ll refactor in small, safe steps with a commit after each step:
Step 1: Extract geometry.py → Commit → Test
Step 2: Extract road.py → Commit → Test
Step 3: Extract visualization.py → Commit → Test
Step 4: Simplify main.py → Commit → Test
Final: Push branch → Open PR → CI validates → Merge
Key principle: After each step, the application should still work perfectly.
Why small steps matter:
- If something breaks, you know exactly which change caused it
- Easy to undo just one step (git revert)
- Clear commit history shows the progression
- Easier for code reviewers to understand
4. Understanding the Monolith Problem
4.1 What is a Monolith?
A monolith (in software) is code where everything lives in one place, tightly coupled together.
Your current main.py:
"""
Road Profile Viewer - Interactive 2D Visualization
===================================================
This module contains the entire application in a single file...
"""
import numpy as np
from dash import Dash, html, dcc, Input, Output
import plotly.graph_objects as go
# Road profile generation (lines 20-60)
def generate_road_profile(num_points=100, x_max=80):
# ... 40 lines of numpy calculations ...
return x, y
# Geometry calculations (lines 60-180)
def calculate_ray_line(angle_degrees, camera_x=0, camera_y=2.0, x_max=80):
# ... calculating ray intersections ...
def find_intersection(x_road, y_road, angle_degrees, camera_x=0, camera_y=1.5):
# ... finding where ray hits road ...
return x_intersect, y_intersect, distance
# Dash application (lines 190-390)
def create_dash_app():
app = Dash(__name__)
app.layout = html.Div([...]) # UI definition
@app.callback(...)
def update_graph(angle):
# ... mixing UI logic with calculations ...
return app
# Entry point
def main():
app = create_dash_app()
app.run(debug=True)
if __name__ == '__main__':
main()
Everything is mixed together:
- Pure math functions (geometry)
- Data generation (road profiles)
- UI framework code (Dash)
- Application logic (callbacks)
4.2 Why Monoliths Become Problems
1. Hard to Navigate
Finding specific functionality requires scrolling through unrelated code.
# You want to modify intersection logic
# But first you have to:
# - Skip past imports (20 lines)
# - Skip past road generation (40 lines)
# - Skip past helper functions (60 lines)
# - Finally reach find_intersection() at line 111
2. Hard to Test (You’ll feel this pain in Chapter 03 (Testing Fundamentals))
# To test find_intersection(), you need:
# - Import everything (including Dash, which starts a web server)
# - Wait for slow imports
# - Risk side effects from other code
# What you want:
from geometry import find_intersection # Fast, focused import
3. Hard to Collaborate
# Two developers working on the same file:
Developer A: Changes line 50 (road generation)
Developer B: Changes line 300 (UI styling)
Git: MERGE CONFLICT (even though changes are unrelated!)
4. High Coupling
When everything is in one file, it’s easy to create invisible dependencies:
def find_intersection(...):
# Accidentally uses a global variable from the UI section
camera_x = CAMERA_POSITION # Where is this defined?
# Now geometry depends on UI - hard to test in isolation!
5. Violates the “One File, One Purpose” Principle
Ask yourself: “What does main.py do?”
Current answer: “Everything! Math, UI, data generation, app startup…”
Better answer (after refactoring): “Starts the application. That’s it.”
4. Part 2: Identifying Natural Boundaries
Before we start refactoring, we need to understand where to split.
4.1 The “Can I Describe It in 3 Words?” Test
If you can describe a module’s purpose in 3 words or less, it’s focused enough.
Good examples:
geometry.py→ “Ray intersection math”road.py→ “Road profile generation”visualization.py→ “Dash UI components”main.py→ “Application entry point”
Bad examples:
utilities.py→ “Miscellaneous useful stuff” (too vague!)helpers.py→ “Helper functions” (which kind??)functions.py→ “Various functions” (meaningless!)
4.2 Analyzing Your Current Code
Let’s categorize every function in main.py by what it depends on:
4.2.1 Category 1: Pure Math Functions (Depend on Nothing)
def calculate_ray_line(angle_degrees, camera_x=0, camera_y=2.0, x_max=80):
"""
Calculate the line representing the camera ray.
Depends on: Only numpy and the input parameters
Side effects: None
Returns: Calculated coordinates
"""
angle_rad = -np.deg2rad(angle_degrees)
slope = np.tan(angle_rad)
# ... pure calculations ...
return x_ray, y_ray
def find_intersection(x_road, y_road, angle_degrees, camera_x=0, camera_y=1.5):
"""
Find intersection between camera ray and road profile.
Depends on: Only numpy and the input parameters
Side effects: None
Returns: Calculated coordinates and distance
"""
angle_rad = -np.deg2rad(angle_degrees)
# ... pure calculations ...
return x_intersect, y_intersect, distance
Key characteristic: These functions don’t care about Dash, UI, or anything else. They’re pure: same inputs always produce same outputs.
Where they belong: geometry.py (focused on geometric calculations)
4.2.2 Category 2: Data Generation (Depends on Math, Not UI)
def generate_road_profile(num_points=100, x_max=80):
"""
Generate a road profile using a clothoid-like approximation.
Depends on: Only numpy
Side effects: None (doesn't draw anything or start servers)
Returns: Road coordinates
"""
x = np.linspace(0, x_max, num_points)
x_norm = x / x_max
y = 0.015 * x_norm**3 * x_max + ...
return x, y
Key characteristic: Generates data, but doesn’t display it.
Where it belongs: road.py (focused on road generation logic)
4.2.3 Category 3: UI and Application Logic (Depends on Everything)
def create_dash_app():
"""
Create and configure the Dash application.
Depends on: Dash, plotly, geometry functions, road functions
Side effects: Creates web server
Returns: Dash app instance
"""
app = Dash(__name__)
app.layout = html.Div([
html.H1("Road Profile Viewer..."),
dcc.Input(id='angle-input', ...),
dcc.Graph(id='road-profile-graph'),
])
@app.callback(
[Output('road-profile-graph', 'figure'), ...],
[Input('angle-input', 'value')]
)
def update_graph(angle):
# Uses road generation
x_road, y_road = generate_road_profile(...)
# Uses geometry
x_intersect, y_intersect, distance = find_intersection(...)
# Creates visualization
fig = go.Figure()
fig.add_trace(...)
return fig, info_text
return app
Key characteristic: Orchestrates everything, creates UI, handles user input.
Where it belongs: visualization.py (focused on presentation layer)
4.2.4 Category 4: Entry Point (Depends on Visualization)
def main():
"""
Main function to run the Dash application.
Depends on: create_dash_app()
Side effects: Starts web server
"""
app = create_dash_app()
print("Starting Road Profile Viewer...")
print("Open your browser and navigate to: http://127.0.0.1:8050/")
app.run(debug=True)
if __name__ == '__main__':
main()
Key characteristic: Just starts the application. Minimal logic.
Where it belongs: main.py (the entry point, as simple as possible)
4.3 The Dependency Hierarchy
Notice the natural dependency flow:
main.py
↓ depends on
visualization.py
↓ depends on
geometry.py, road.py
↓ depends on
numpy (external library)
Key insight: Dependencies should flow one direction only.
- ✅
visualization.pycan import fromgeometry.py - ❌
geometry.pyshould NOT import fromvisualization.py
Why? This prevents circular dependencies (Module A imports Module B imports Module A…).
5. Part 3: The Refactoring Plan
5.1 Target Structure
road-profile-viewer/
├── main.py # Entry point only (~15 lines)
├── geometry.py # Pure math functions (~80 lines)
├── road.py # Road generation (~60 lines)
├── visualization.py # Dash app and UI (~200 lines)
└── (other files remain the same)
5.2 Step-by-Step Refactoring Guide
We’ll refactor in small, safe steps. After each step, we’ll verify the application still works.
6. Part 4: Hands-On Refactoring (Follow Along!)
6.1 Step 1: Create the New Module Files
First, let’s create empty files for our new modules:
# In your road-profile-viewer directory
$ touch geometry.py
$ touch road.py
$ touch visualization.py
Verify the structure:
$ ls *.py
geometry.py main.py road.py visualization.py
6.2 Step 2: Extract Pure Geometry Functions → geometry.py
Goal: Move all pure math functions that don’t depend on Dash or road generation.
Open geometry.py and add:
"""
Geometry calculations for ray-road intersection.
This module contains pure mathematical functions for calculating:
- Camera ray lines at various angles
- Intersection points between rays and road profiles
- Distances from camera to intersection points
All functions are pure (no side effects) and depend only on numpy.
"""
import numpy as np
def calculate_ray_line(
angle_degrees: float,
camera_x: float = 0,
camera_y: float = 2.0,
x_max: float = 80
) -> tuple[np.ndarray, np.ndarray]:
"""
Calculate the line representing the camera ray.
Parameters
----------
angle_degrees : float
Angle in degrees from the positive x-axis (measured downward from horizontal)
camera_x : float, optional
X-coordinate of camera position (default: 0)
camera_y : float, optional
Y-coordinate of camera position (default: 2.0)
x_max : float, optional
Maximum x extent for the ray (default: 80)
Returns
-------
tuple[np.ndarray, np.ndarray]
x and y coordinates of the ray line
Examples
--------
>>> x_ray, y_ray = calculate_ray_line(-10.0)
>>> len(x_ray)
2
"""
# Convert angle to radians (angle is measured downward from horizontal)
# Negative angle because y-axis points up but we measure downward angle
angle_rad = -np.deg2rad(angle_degrees)
# Calculate slope
if np.abs(np.cos(angle_rad)) < 1e-10:
# Vertical line case
return np.array([camera_x, camera_x]), np.array([camera_y, -10])
slope = np.tan(angle_rad)
# Calculate x range where the ray is valid
# The ray should extend from the camera to where it would intersect y=0 or beyond
if angle_degrees < 0 or angle_degrees > 180:
# Ray going upward - just show a short segment
x_end = min(camera_x + 20, x_max)
else:
# Ray going downward - extend to x_max
x_end = x_max
# Generate points for the ray
x_ray = np.array([camera_x, x_end])
y_ray = camera_y + slope * (x_ray - camera_x)
return x_ray, y_ray
def find_intersection(
x_road: np.ndarray,
y_road: np.ndarray,
angle_degrees: float,
camera_x: float = 0,
camera_y: float = 1.5
) -> tuple[float | None, float | None, float | None]:
"""
Find the intersection point between the camera ray and the road profile.
Parameters
----------
x_road : np.ndarray
X-coordinates of the road profile
y_road : np.ndarray
Y-coordinates of the road profile
angle_degrees : float
Angle of the camera ray in degrees
camera_x : float, optional
X-coordinate of camera position (default: 0)
camera_y : float, optional
Y-coordinate of camera position (default: 1.5)
Returns
-------
tuple[float | None, float | None, float | None]
x, y coordinates of intersection and distance from camera,
or (None, None, None) if no intersection found
Examples
--------
>>> x_road = np.array([0, 10, 20, 30])
>>> y_road = np.array([0, 2, 3, 4])
>>> x, y, dist = find_intersection(x_road, y_road, -10.0)
>>> x is not None
True
"""
angle_rad = -np.deg2rad(angle_degrees)
# Handle vertical ray
if np.abs(np.cos(angle_rad)) < 1e-10:
return None, None, None
slope = np.tan(angle_rad)
# Ray equation: y = camera_y + slope * (x - camera_x)
# Check each segment of the road for intersection
for i in range(len(x_road) - 1):
x1, y1 = x_road[i], y_road[i]
x2, y2 = x_road[i + 1], y_road[i + 1]
# Skip if this segment is behind the camera
if x2 <= camera_x:
continue
# Calculate y values of the ray at x1 and x2
ray_y1 = camera_y + slope * (x1 - camera_x)
ray_y2 = camera_y + slope * (x2 - camera_x)
# Check if the ray crosses the road segment
# The ray intersects if it's on different sides of the road at x1 and x2
diff1 = ray_y1 - y1
diff2 = ray_y2 - y2
if diff1 * diff2 <= 0: # Sign change or zero indicates intersection
# Linear interpolation to find exact intersection point
if abs(diff2 - diff1) < 1e-10:
# Parallel lines
t = 0
else:
t = diff1 / (diff1 - diff2)
# Interpolate to find intersection point
x_intersect = x1 + t * (x2 - x1)
y_intersect = y1 + t * (y2 - y1)
# Calculate distance from camera to intersection
distance = np.sqrt((x_intersect - camera_x)**2 + (y_intersect - camera_y)**2)
return x_intersect, y_intersect, distance
return None, None, None
What we just did:
- ✅ Copied all geometry functions from
main.py - ✅ Added comprehensive docstrings (good practice!)
- ✅ Added type hints (helps with IDE autocomplete)
- ✅ Kept all the logic exactly the same (no changes to calculations)
Now, update main.py to use the new module:
At the top of main.py, add:
from geometry import calculate_ray_line, find_intersection
Then delete the calculate_ray_line() and find_intersection() function definitions from main.py (they’re now in geometry.py).
Test that it still works:
$ uv run road-profile-viewer
# Application should start normally
# Open browser to http://127.0.0.1:8050/
# Verify ray intersection still works
✅ Checkpoint: If the application works, you’ve successfully extracted geometry!
Now commit your progress:
$ git add geometry.py main.py
$ git commit -m "Extract geometry functions to geometry.py
- Move calculate_ray_line() and find_intersection() to geometry.py
- Add comprehensive docstrings and type hints
- Update main.py to import from geometry module
- Application still works identically
This separates pure math functions from UI code, making them easier to test."
6.3 Step 3: Extract Road Generation → road.py
Goal: Move road generation logic to its own module.
Open road.py and add:
"""
Road profile generation.
This module handles generation of road profiles using various mathematical
curves (currently clothoid-like approximations).
All functions are pure (no side effects) and depend only on numpy.
"""
import numpy as np
def generate_road_profile(
num_points: int = 100,
x_max: float = 80
) -> tuple[np.ndarray, np.ndarray]:
"""
Generate a road profile using a clothoid-like approximation.
A clothoid (Euler spiral) is a curve whose curvature increases linearly
with its arc length. This function approximates it with a polynomial curve.
Parameters
----------
num_points : int, optional
Number of points to generate (default: 100)
x_max : float, optional
Maximum x-coordinate value (default: 80)
Returns
-------
tuple[np.ndarray, np.ndarray]
x and y coordinates of the road profile
Examples
--------
>>> x, y = generate_road_profile(num_points=50, x_max=40)
>>> len(x)
50
>>> x[0], y[0]
(0.0, 0.0)
"""
# Generate equidistant x points from 0 to x_max
x = np.linspace(0, x_max, num_points)
# Create a clothoid-like curve using a combination of polynomial and sinusoidal terms
# This creates a road that starts flat and gradually curves
# Normalize x for the calculation
x_norm = x / x_max
# Clothoid approximation: starts flat, gradually increases curvature
# Scale to keep maximum height around 8m (realistic road profile)
y = (0.015 * x_norm**3 * x_max +
0.3 * np.sin(2 * np.pi * x_norm) +
0.035 * x_norm * x_max)
# Ensure it starts at (0, 0)
y = y - y[0]
return x, y
Update main.py:
Add the import:
from road import generate_road_profile
Delete the generate_road_profile() function definition from main.py.
Test again:
$ uv run road-profile-viewer
# Verify road generation still works
✅ Checkpoint: Application should still work perfectly!
Commit this step:
$ git add road.py main.py
$ git commit -m "Extract road generation to road.py
- Move generate_road_profile() to road.py
- Add docstrings explaining clothoid approximation
- Update main.py to import from road module
- Application still works identically
Separates data generation from geometry and UI."
6.4 Step 4: Extract Dash UI → visualization.py
Goal: Move all Dash/UI code to a dedicated module.
Open visualization.py and add:
"""
Visualization layer for the Road Profile Viewer.
This module contains all Dash UI components, layout definitions, and callbacks.
It orchestrates the geometry and road modules to create an interactive visualization.
"""
import numpy as np
from dash import Dash, html, dcc, Input, Output
import plotly.graph_objects as go
from geometry import calculate_ray_line, find_intersection
from road import generate_road_profile
def create_dash_app() -> Dash:
"""
Create and configure the Dash application.
Returns
-------
Dash
Configured Dash application instance ready to run
"""
# Initialize the Dash app
app = Dash(__name__)
# Define the layout
app.layout = html.Div([
html.H1("Road Profile Viewer with Camera Ray Intersection",
style={'textAlign': 'center', 'color': '#2c3e50', 'marginBottom': '20px'}),
html.Div([
html.Label("Camera Ray Angle (degrees from horizontal):",
style={'fontWeight': 'bold', 'marginRight': '10px'}),
dcc.Input(
id='angle-input',
type='number',
value=-1.1,
step=0.1,
style={'marginRight': '20px', 'padding': '5px', 'width': '100px'}
),
html.Span(id='intersection-info',
style={'color': '#e74c3c', 'fontWeight': 'bold'})
], style={'textAlign': 'center', 'marginBottom': '20px', 'padding': '10px'}),
dcc.Graph(id='road-profile-graph', style={'height': '400px'}),
html.Div([
html.H3("Instructions:", style={'color': '#2c3e50'}),
html.Ul([
html.Li("The dark grey line represents the road profile"),
html.Li("The red point at (0, 2.0) represents the camera position"),
html.Li("The blue line shows the camera ray at the specified angle"),
html.Li("The green point shows where the ray intersects the road"),
html.Li("Hover over the green point to see the distance from camera to intersection"),
html.Li("Adjust the angle to see how the intersection point changes"),
html.Li("Negative angles point downward, positive angles point upward")
])
], style={'margin': '20px', 'padding': '20px', 'backgroundColor': '#ecf0f1', 'borderRadius': '5px'})
])
# Define the callback to update the graph
@app.callback(
[Output('road-profile-graph', 'figure'),
Output('intersection-info', 'children')],
[Input('angle-input', 'value')]
)
def update_graph(angle):
"""
Update the graph based on the input angle.
Parameters
----------
angle : float
Camera ray angle in degrees
Returns
-------
tuple
(plotly figure, info text)
"""
if angle is None:
angle = -1.1
# Generate road profile
x_road, y_road = generate_road_profile(num_points=100, x_max=80)
# Camera position
camera_x, camera_y = 0, 2.0
# Find intersection first to determine ray length
x_intersect, y_intersect, distance = find_intersection(
x_road, y_road, angle, camera_x, camera_y
)
# Calculate adaptive ray line based on intersection
if x_intersect is not None:
# Ray goes from camera to intersection point
x_ray = np.array([camera_x, x_intersect])
y_ray = np.array([camera_y, y_intersect])
else:
# No intersection - show a short ray (20 units or to edge of plot)
angle_rad = -np.deg2rad(angle)
if np.abs(np.cos(angle_rad)) < 1e-10:
# Vertical line
x_ray = np.array([camera_x, camera_x])
y_ray = np.array([camera_y, camera_y - 10])
else:
slope = np.tan(angle_rad)
x_end = min(camera_x + 20, 80)
y_end = camera_y + slope * (x_end - camera_x)
x_ray = np.array([camera_x, x_end])
y_ray = np.array([camera_y, y_end])
# Create figure
fig = go.Figure()
# Add road profile
fig.add_trace(go.Scatter(
x=x_road,
y=y_road,
mode='lines+markers',
name='Road Profile',
line=dict(color='#4a4a4a', width=3),
marker=dict(size=4, color='#4a4a4a'),
hovertemplate='Road<br>x: %{x:.2f}<br>y: %{y:.2f}<extra></extra>'
))
# Add camera point
fig.add_trace(go.Scatter(
x=[camera_x],
y=[camera_y],
mode='markers',
name='Camera',
marker=dict(size=12, color='red', symbol='circle'),
hovertemplate='Camera<br>Position: (%{x:.2f}, %{y:.2f})<extra></extra>'
))
# Add camera ray
fig.add_trace(go.Scatter(
x=x_ray,
y=y_ray,
mode='lines',
name=f'Camera Ray ({angle}°)',
line=dict(color='blue', width=2, dash='dash'),
hovertemplate='Camera Ray<br>x: %{x:.2f}<br>y: %{y:.2f}<extra></extra>'
))
# Add intersection point if it exists
info_text = ""
if x_intersect is not None:
fig.add_trace(go.Scatter(
x=[x_intersect],
y=[y_intersect],
mode='markers',
name='Intersection',
marker=dict(size=15, color='green', symbol='star'),
hovertemplate=f'Intersection Point<br>Position: ({x_intersect:.2f}, {y_intersect:.2f})<br>Distance from camera: {distance:.2f}<extra></extra>'
))
info_text = f"Intersection found at ({x_intersect:.2f}, {y_intersect:.2f}) | Distance: {distance:.2f} units"
else:
info_text = "No intersection found with current angle"
# Update layout
fig.update_layout(
xaxis_title="X Position (m)",
yaxis_title="Y Position (m)",
hovermode='closest',
showlegend=True,
legend=dict(
x=1.02,
y=1,
xanchor='left',
yanchor='top',
bgcolor='rgba(255,255,255,0.8)',
bordercolor='#dee2e6',
borderwidth=1
),
plot_bgcolor='#f8f9fa',
xaxis=dict(
gridcolor='#dee2e6',
range=[-2, 82],
constrain='domain'
),
yaxis=dict(
gridcolor='#dee2e6',
scaleanchor='x',
scaleratio=1,
range=[-0.5, 10],
constrain='domain'
),
margin=dict(l=50, r=150, t=30, b=50)
)
return fig, info_text
return app
Update main.py:
Your main.py should now be very simple:
"""
Road Profile Viewer - Entry Point
This is the main entry point for the Road Profile Viewer application.
All functionality is implemented in separate modules:
- geometry.py: Ray intersection calculations
- road.py: Road profile generation
- visualization.py: Dash UI and visualization
To run the application:
$ uv run road-profile-viewer
"""
from visualization import create_dash_app
def main():
"""
Main function to run the Dash application.
"""
app = create_dash_app()
print("Starting Road Profile Viewer...")
print("Open your browser and navigate to: http://127.0.0.1:8050/")
print("Press Ctrl+C to stop the server.")
app.run(debug=True)
if __name__ == '__main__':
main()
Test the complete refactoring:
$ uv run road-profile-viewer
# Verify EVERYTHING still works:
# - Road renders correctly
# - Ray angle adjusts
# - Intersection point updates
# - Distance displays
✅ Checkpoint: If everything works, you’ve successfully refactored your monolith!
Commit this major step:
$ git add visualization.py main.py
$ git commit -m "Extract UI layer to visualization.py and simplify main.py
- Move create_dash_app() and all UI code to visualization.py
- Simplify main.py to just entry point (~20 lines)
- visualization.py imports from geometry and road modules
- Application works identically to before refactoring
Completes modular refactoring: main → visualization → geometry/road"
7. Verifying the Refactoring
7.1 Check Your Module Structure
$ ls *.py
geometry.py main.py road.py visualization.py
7.2 Check Line Counts
$ wc -l *.py
175 geometry.py # Pure geometry calculations
67 road.py # Road generation
203 visualization.py # All UI code
20 main.py # Just the entry point
---
465 total
Notice:
main.pywent from 390 lines to 20 lines!- Each module has a clear, focused purpose
- Total lines slightly increased (due to docstrings) - that’s good! Documentation matters.
7.3 Verify the Dependency Flow
# main.py imports:
from visualization import create_dash_app
# visualization.py imports:
from geometry import calculate_ray_line, find_intersection
from road import generate_road_profile
# geometry.py imports:
import numpy as np # Only external dependency
# road.py imports:
import numpy as np # Only external dependency
Dependency graph:
main.py
↓
visualization.py
↓ ↓
geometry.py road.py
↓ ↓
numpy numpy
✅ One-directional dependencies - no cycles!
8. Part 6: Benefits You’ve Just Gained
8.1 Easy Navigation
Before:
# "Where's the intersection calculation?"
# *Scrolls through 390 lines*
After:
# "Where's the intersection calculation?"
# Opens geometry.py → find_intersection() is right there
8.2 Focused Changes
Before:
# Changing road generation algorithm
# File: main.py (390 lines - might accidentally break UI)
After:
# Changing road generation algorithm
# File: road.py (67 lines - can't accidentally touch UI code)
8.3 Better Collaboration
Before:
Student A: Editing main.py lines 50-120 (road generation)
Student B: Editing main.py lines 200-350 (UI styling)
Result: MERGE CONFLICT (even though changes are independent!)
After:
Student A: Editing road.py
Student B: Editing visualization.py
Result: No conflicts! Different files can be edited simultaneously.
8.4 Testability (Preview of Chapter 03)
Before:
# To test find_intersection()
from main import find_intersection # Imports EVERYTHING
# Dash starts, slow imports, potential side effects
After:
# To test find_intersection()
from geometry import find_intersection # Only imports geometry
# Fast, focused, no side effects
8.5 Reusability
Before:
# Want to use find_intersection() in another project?
# Copy-paste from main.py → bring along unnecessary Dash code
After:
# Want to use find_intersection() in another project?
# Just copy geometry.py → clean, standalone module
9. Part 7: Common Pitfalls and Solutions
9.1 Pitfall 1: Circular Imports
The Error:
ImportError: cannot import name 'generate_road_profile' from partially initialized module 'road'
(most likely due to a circular import)
The Cause:
# visualization.py
from road import generate_road_profile
# road.py (BAD!)
from visualization import create_dash_app # Why would road.py need UI code??
The Solution:
- Rule: Lower-level modules (geometry, road) should never import from higher-level modules (visualization, main)
- Dependencies flow one direction: main → visualization → geometry/road
9.2 Pitfall 2: Unclear Module Boundaries
The Confusion:
# Where does camera_x and camera_y belong?
# - geometry.py? (it's used in calculations)
# - visualization.py? (it's a UI parameter)
# - A new config.py file?
The Solution:
- If it’s hardcoded: Keep it as a parameter default in the function signature
- If it’s user-configurable: Keep it in visualization.py (UI decides camera position)
- Don’t create “config.py” or “constants.py” prematurely - you’ll know when you actually need it
9.3 Pitfall 3: Over-Refactoring
The Temptation:
Should I create:
- geometry_helpers.py?
- geometry_utils.py?
- ray_calculations.py (separate from geometry.py)?
- intersection_math.py?
The Solution:
- YAGNI Principle: “You Ain’t Gonna Need It”
- Start with 3-4 focused modules
- Only split further when a module becomes too large (>300 lines) or has multiple distinct purposes
- Avoid “utility/helper” modules - they become dumping grounds for unrelated code
9.4 Pitfall 4: Forgetting to Update Imports
The Error:
# In main.py
def main():
x, y = generate_road_profile() # NameError: generate_road_profile not defined
The Fix:
# Add the import at the top
from road import generate_road_profile
Pro Tip: Use your IDE’s auto-import feature (Ctrl+. in VS Code) to add imports automatically.
10. Part 8: Running Your CI Checks
Your refactoring should pass all existing CI checks!
# Run Ruff (style check)
$ uv run ruff check .
All checks passed!
# Run Ruff format check
$ uv run ruff format --check .
All files formatted correctly!
# Run Pyright (type check)
$ uv run pyright
0 errors, 0 warnings
If you get errors:
Ruff errors:
- Likely unused imports in
main.py(you removed functions but forgot to remove their imports) - Fix: Remove the imports you no longer need
Pyright errors:
- Likely missing type hints or imports
- Fix: Add the missing imports or type annotations
11. Pushing Your Feature Branch and Creating a Pull Request
You’ve been committing after each step. Now it’s time to push your branch and create a PR!
11.1 Review Your Commits
$ git log --oneline
7h8i9j0 Extract UI layer to visualization.py and simplify main.py
d4e5f6g Extract road generation to road.py
a1b2c3d Extract geometry functions to geometry.py
Clean, logical progression! Each commit represents one focused refactoring step.
11.2 Push to GitHub
$ git push -u origin feature/refactor-to-modules
This uploads all your commits to GitHub.
11.3 Create Pull Request
$ gh pr create --title "Refactor: Split monolithic main.py into focused modules" \
--body "Refactors monolithic main.py (390 lines) into four focused modules:
**New Structure:**
- geometry.py: Pure math functions (ray intersection)
- road.py: Road generation logic
- visualization.py: Dash UI layer
- main.py: Entry point only (20 lines)
**Benefits:**
- Easier navigation and testing
- Reduced merge conflicts
- Better code organization
- Prepares for Chapter 03 (Testing Fundamentals) (testing)
**Verification:**
- All Ruff/Pyright checks pass
- Application works identically
- Clean dependency flow (no cycles)"
Or create the PR on GitHub’s web interface if you prefer.
11.4 Wait for CI Checks
GitHub Actions will automatically run:
✅ Ruff check (style)
✅ Ruff format check
✅ Pyright (types)
All should pass since you’ve been running them locally!
12. Leveraging AI Coding Assistants for Refactoring
12.1 The Modern Developer’s Workflow
Important Disclaimer: Before using AI assistants for refactoring, you MUST understand the principles we just learned:
- ✅ What makes good module boundaries
- ✅ How to avoid circular dependencies
- ✅ The “3-word description” test
- ✅ One-directional dependency flow
- ✅ Why separation of concerns matters
Why this matters: Without understanding these principles, you won’t be able to:
- Formulate effective prompts
- Evaluate if the AI’s suggestions are correct
- Catch subtle mistakes in the generated code
- Make informed decisions about code organization
The AI is a tool, not a replacement for your knowledge!
12.2 How AI Can Accelerate Refactoring
Once you understand the principles, AI coding assistants can help with:
- Mechanical code movement - Moving functions between files
- Updating imports - Adding/removing import statements
- Writing boilerplate - Module docstrings, type hints
- Maintaining consistency - Ensuring all references are updated
What AI cannot do:
- Decide WHERE to split your code (requires human judgment)
- Understand your project’s specific architecture needs
- Evaluate trade-offs between different approaches
- Ensure the refactoring aligns with your team’s conventions
12.3 Example: AI-Assisted Refactoring Prompt
Scenario: You’ve planned your refactoring strategy (manually!) and now want AI to help with execution.
Effective Prompt:
You will refactor the monolithic `src/road_profile_viewer/main.py`
(390 lines) into four focused modules:
- geometry.py - Pure math functions (ray intersection calculations)
- road.py - Road profile generation
- visualization.py - Dash UI layer
- main.py - Simplified entry point (~20 lines)
Create a feature branch called feature/refactor-to-modules and make
4 incremental commits (one per step).
---
Step 1: Extract Geometry Module
Create geometry.py in the src/road_profile_viewer/ directory.
Requirements:
- Copy calculate_ray_line() and find_intersection() from main.py
- Add proper imports: import numpy as np
- Keep all docstrings and type hints
- Add a module docstring explaining this is the geometry module
Commit message format:
"refactor: extract geometry functions to separate module
Extract calculate_ray_line() and find_intersection() from main.py
into a new geometry.py module to improve code organization.
This is step 1 of refactoring the monolithic main.py (390 lines)
into focused modules."
---
Step 2: Extract Road Module
Create road.py in the src/road_profile_viewer/ directory.
Requirements:
- Copy generate_road_profile() from main.py
- Add proper imports: import numpy as np
- Keep all docstrings
- Add a module docstring
Commit message format:
"refactor: extract road profile generation to separate module
Extract generate_road_profile() from main.py into road.py
to improve separation of concerns.
This is step 2 of refactoring the monolithic main.py."
---
Step 3: Extract Visualization Module
Create visualization.py in the src/road_profile_viewer/ directory.
Requirements:
- Copy create_dash_app() and ALL UI code from main.py
- Add imports for Dash, plotly
- Use ABSOLUTE imports (not relative):
from road_profile_viewer.geometry import find_intersection
from road_profile_viewer.road import generate_road_profile
- Add a module docstring
Why absolute imports? When running the module directly, relative
imports fail with ImportError. Absolute imports work in all contexts.
Commit message format:
"refactor: extract Dash UI layer to visualization module
Extract create_dash_app() and all UI code from main.py into
visualization.py using absolute imports.
This is step 3 of refactoring. The visualization module now
handles all Dash UI components and callbacks."
---
Step 4: Simplify main.py
Replace main.py with a simplified version (~26 lines).
Requirements:
- Only keep the entry point functionality
- Use ABSOLUTE import:
from road_profile_viewer.visualization import create_dash_app
- Keep main() function and if __name__ == "__main__": block
- Add concise module docstring
Template:
"""
Road Profile Viewer - Interactive 2D Visualization
Main entry point for the road profile viewer application.
"""
from road_profile_viewer.visualization import create_dash_app
def main():
app = create_dash_app()
print("Starting Road Profile Viewer...")
print("Open your browser: http://127.0.0.1:8050/")
app.run(debug=True)
if __name__ == "__main__":
main()
Commit message format:
"refactor: simplify main.py to entry point only
Refactor main.py from 390 lines to 26 lines by extracting
implementation to separate modules (geometry.py, road.py,
visualization.py).
This completes the refactoring with clear separation of concerns."
12.4 Why This Prompt Works
Clear Structure:
- ✅ Explicit file names and locations
- ✅ Step-by-step breakdown
- ✅ Specific requirements for each step
- ✅ Commit messages following conventions
Includes Context:
- ✅ Explains WHY certain decisions are made (absolute imports)
- ✅ Specifies what to keep (docstrings, type hints)
- ✅ Shows the desired end state
Enables Verification:
- ✅ Each step is independently testable
- ✅ Commit messages document the progression
- ✅ Clear success criteria (line counts, structure)
12.5 After AI Generates Code: Your Responsibilities
Never blindly accept AI-generated code! Always:
- Read every line - Understand what changed
- Test after each step - Run
uv run road-profile-viewer - Verify imports - Check for circular dependencies
- Run CI checks - Ensure Ruff, Pyright pass
- Review commits - Check git diff for unexpected changes
- Validate structure - Does it match your architectural plan?
Red Flags to Watch For:
- ❌ Circular imports (geometry imports from visualization)
- ❌ Missing imports or wrong import paths
- ❌ Lost functionality (functions deleted instead of moved)
- ❌ Changed logic (AI “improved” something it shouldn’t)
- ❌ Inconsistent naming or style
12.6 The Human-AI Collaboration Model
Effective refactoring with AI:
Human: Strategic Thinking
├── Identify monolith problems
├── Decide module boundaries
├── Plan dependency hierarchy
└── Define success criteria
↓
AI: Tactical Execution
├── Move code between files
├── Update import statements
├── Write boilerplate code
└── Format commit messages
↓
Human: Verification & Quality
├── Test functionality
├── Review architecture
├── Check for subtle bugs
└── Validate against CI
You are the architect. AI is the construction crew.
12.7 Learning Exercise: Practice Without AI First
Recommendation for students:
- First time: Do the refactoring manually (today’s exercise)
- You’ll deeply understand the challenges
- Build intuition for what can go wrong
- Learn to spot circular dependencies
- Second time: Use AI assistance (future project)
- Now you can evaluate AI’s suggestions
- You’ll catch its mistakes
- You’ll write better prompts
Why this order matters: You can’t effectively use a tool you don’t understand. Learn the principles first, then leverage automation.
17. Summary: What You’ve Accomplished
17.1 Before Refactoring
main.py (390 lines)
└── Everything mixed together:
├── Geometry math
├── Road generation
├── Dash UI
└── Application startup
Problems:
- Hard to find code
- Hard to test
- Merge conflicts
- Tight coupling
17.2 After Refactoring
├── geometry.py (175 lines) - Pure math functions
├── road.py (67 lines) - Data generation
├── visualization.py (203 lines) - UI layer
└── main.py (20 lines) - Entry point
Benefits:
- ✅ Easy navigation (clear module names)
- ✅ Testable (can import geometry without Dash)
- ✅ Collaborative (edit different files simultaneously)
- ✅ Reusable (pure functions in standalone modules)
- ✅ Maintainable (focused, single-responsibility modules)
17.3 Key Principles You’ve Learned
- Separation of Concerns
- Each module does one thing well
- Pure functions separate from UI code
- Dependency Direction
- Lower-level modules (geometry, road) don’t import from higher-level (visualization)
- Prevents circular dependencies
- The “3-Word Description” Test
- If you can’t describe a module’s purpose in 3 words, it’s not focused enough
- YAGNI (“You Ain’t Gonna Need It”)
- Don’t create modules/abstractions until you actually need them
- Start simple, refactor when necessary
17.4 What’s Next?
Chapter 03 (Testing Fundamentals): Testing Fundamentals
Now that your code is modular, you can:
- Test
geometry.pyfunctions in isolation (fast unit tests) - Test
road.pywithout starting the UI - Understand the testing pyramid
- Use LLMs to generate test boilerplate (but you’ll verify correctness!)
The setup is complete: Your code is now structured for professional test-driven development.
18. Key Takeaways
Remember these principles:
- Monoliths grow organically - One file is fine initially, but quickly becomes a problem
- Refactoring is NOT rewriting - Same functionality, better structure
- Modules should have clear boundaries - Pure functions vs. UI code
- Dependencies should flow one direction - Prevents circular imports
- Testability emerges from modularity - Can’t test what you can’t import
- The “3-Word Test” - Can you describe the module’s purpose concisely?
- YAGNI - Don’t over-engineer. Start simple, refactor when needed.
- CI should pass - Refactoring doesn’t change functionality, so tests still pass
You’re now ready for Chapter 03 (Testing Fundamentals): Testing!
Your modular code structure makes testing not just possible, but natural and easy.
19. Further Reading
On Refactoring:
- Martin Fowler’s “Refactoring: Improving the Design of Existing Code”
- Key quote: “Any fool can write code that a computer can understand. Good programmers write code that humans can understand.”
On Module Design:
- Robert C. Martin’s “Clean Code”
- SOLID Principles (you’re already applying Single Responsibility Principle!)
On Python Module Organization:
- Real Python: Python Modules and Packages
- PEP 8: Package and Module Names
Interactive Learning:
- Refactoring Guru: Code Smells and Refactoring
Last Updated: 2025-10-21 Prerequisites: Lectures 1-3 (Git, Code Quality, CI/CD) Next Lecture: Chapter 03 (Testing Fundamentals) - Testing Fundamentals