Home

02 Code Quality in Practice: Refactoring - From Monolith to Modules

lecture refactoring modularity code-organization separation-of-concerns software-architecture

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:

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:

  1. Understand why monolithic code becomes unmaintainable as projects grow
  2. Learn to identify natural boundaries in your code (where to split)
  3. Refactor a monolithic file into focused, single-purpose modules
  4. Organize imports and dependencies to avoid circular dependencies
  5. Experience the benefits of modular code (easier navigation, testing, collaboration)
  6. Prepare your codebase for testing (Chapter 03 (Testing Fundamentals)) by separating concerns

What you WON’T learn (yet):


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:

  1. Create a feature branch
  2. Implement your changes
  3. Push to GitHub
  4. Open a Pull Request
  5. Automated CI checks run
  6. Code review
  7. Merge when approved
  8. 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:

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:


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:

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:

Bad examples:

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.

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:

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:

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:

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:

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:

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:

Pyright errors:


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:

Why this matters: Without understanding these principles, you won’t be able to:

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:

  1. Mechanical code movement - Moving functions between files
  2. Updating imports - Adding/removing import statements
  3. Writing boilerplate - Module docstrings, type hints
  4. Maintaining consistency - Ensuring all references are updated

What AI cannot do:

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:

Includes Context:

Enables Verification:

12.5 After AI Generates Code: Your Responsibilities

Never blindly accept AI-generated code! Always:

  1. Read every line - Understand what changed
  2. Test after each step - Run uv run road-profile-viewer
  3. Verify imports - Check for circular dependencies
  4. Run CI checks - Ensure Ruff, Pyright pass
  5. Review commits - Check git diff for unexpected changes
  6. Validate structure - Does it match your architectural plan?

Red Flags to Watch For:

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:

  1. 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
  2. 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:

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:

17.3 Key Principles You’ve Learned

  1. Separation of Concerns
    • Each module does one thing well
    • Pure functions separate from UI code
  2. Dependency Direction
    • Lower-level modules (geometry, road) don’t import from higher-level (visualization)
    • Prevents circular dependencies
  3. The “3-Word Description” Test
    • If you can’t describe a module’s purpose in 3 words, it’s not focused enough
  4. 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:

The setup is complete: Your code is now structured for professional test-driven development.


18. Key Takeaways

Remember these principles:

  1. Monoliths grow organically - One file is fine initially, but quickly becomes a problem
  2. Refactoring is NOT rewriting - Same functionality, better structure
  3. Modules should have clear boundaries - Pure functions vs. UI code
  4. Dependencies should flow one direction - Prevents circular imports
  5. Testability emerges from modularity - Can’t test what you can’t import
  6. The “3-Word Test” - Can you describe the module’s purpose concisely?
  7. YAGNI - Don’t over-engineer. Start simple, refactor when needed.
  8. 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:

On Module Design:

On Python Module Organization:

Interactive Learning:


Last Updated: 2025-10-21 Prerequisites: Lectures 1-3 (Git, Code Quality, CI/CD) Next Lecture: Chapter 03 (Testing Fundamentals) - Testing Fundamentals

© 2026 Dominik Mueller   •  Powered by Soopr   •  Theme  Moonwalk