Unit 16 Project: Build and Ship Your Capstone

Five days, one smallest useful version, and someone else able to run it

Advanced 20 min

In this lesson

In Unit 15's project you wrote a plan. This lesson turns it into a thing that runs, on a schedule short enough that you will finish it: five days of a few hours each.

The shape is the same whichever track you chose. Cut the scope, build a core you can test without an interface, give it somewhere to keep data, put the thinnest possible interface on top, then make it runnable by someone else. That last step is the one people skip, and it is the one that makes it a project rather than a folder.

Explain it like I’m 5

Shipping means making a small version real enough that someone else can try it. Not finished. Real.

Start from the plan, then cut it in half

Open your plan from Unit 15 and find the feature list. Now cut it to the features without which the thing has no point at all.

That is not modesty, it is arithmetic: version 0.1 is the version that exists. Every feature you add before it works is a feature you might build twice, because the shape of the first three often changes what the fourth should be.

Example
FEATURES = [
    ("add a link with a title", 2, True),
    ("list saved links", 2, True),
    ("mark one as read", 1, True),
    ("tags and tag filtering", 5, False),
    ("full-text search", 6, False),
    ("email digest every Friday", 8, False),
    ("dark mode", 3, False),
]

def split_scope(features):
    """The smallest useful version, and everything that waits."""
    core = [(name, hours) for name, hours, is_core in features if is_core]
    later = [name for name, _hours, is_core in features if not is_core]
    return core, later

core, later = split_scope(FEATURES)
estimated = sum(hours for _name, hours in core)

print(f"v0.1: {estimated}h estimated, so budget {estimated * 2}h")
for name, hours in core:
    print(f"      {hours}h  {name}")
print(f"later: {len(later)} ideas, none of them blocking")
for name in later:
    print(f"          {name}")
Output
v0.1: 5h estimated, so budget 10h
      2h  add a link with a title
      2h  list saved links
      1h  mark one as read
later: 4 ideas, none of them blocking
          tags and tag filtering
          full-text search
          email digest every Friday
          dark mode
A reading-list app, cut to the three features that make it a reading list.

Build the core first, and keep it away from the interface

The single most useful structural decision is to write the logic as plain functions that know nothing about screens, requests, or files — then put the interface on top.

The reason is testing. A function that takes data and returns data can be checked in one line. The same logic tangled into a Flask route needs a running server and a browser to check at all.

Tests point at the middle layer, which is why the middle layer must not know about the top one.
Example
def add_link(links, url, title):
    """Add a link, refusing a duplicate URL. Returns the new count."""
    if any(link["url"] == url for link in links):
        raise ValueError(f"already saved: {url}")
    links.append({"url": url, "title": title, "read": False})
    return len(links)

def mark_read(links, url):
    """Mark one link as read. True if that changed anything."""
    for link in links:
        if link["url"] == url:
            was_read = link["read"]
            link["read"] = True
            return not was_read
    return False

def summary(links):
    """One line describing the whole list."""
    unread = sum(1 for link in links if not link["read"])
    return f"{len(links)} saved, {unread} unread"

READING_LIST = []
add_link(READING_LIST, "https://example.com/a", "Descriptors explained")
add_link(READING_LIST, "https://example.com/b", "Reading a signature")
print(summary(READING_LIST))

print("marked read:", mark_read(READING_LIST, "https://example.com/a"))
print("again:      ", mark_read(READING_LIST, "https://example.com/a"))
print(summary(READING_LIST))

try:
    add_link(READING_LIST, "https://example.com/a", "Descriptors explained")
except ValueError as error:
    print("refused:    ", error)
Output
2 saved, 2 unread
marked read: True
again:       False
2 saved, 1 unread
refused:     already saved: https://example.com/a
The whole product, with no interface and no database, and you can already test it.

Give it somewhere to keep things

Right now everything vanishes when the program ends, which is the problem Unit 8 exists to solve. For a capstone, sqlite3 is almost always the right answer: it is in the standard library, it is one file on disk, and it needs no server.

Example
import sqlite3

connection = sqlite3.connect(":memory:")      # a real file in your project
connection.execute("""
    CREATE TABLE links (
        url   TEXT PRIMARY KEY,
        title TEXT NOT NULL,
        read  INTEGER NOT NULL DEFAULT 0
    )
""")

ROWS = [("https://example.com/a", "Descriptors explained"),
        ("https://example.com/b", "Reading a signature")]
connection.executemany("INSERT INTO links (url, title) VALUES (?, ?)", ROWS)
connection.execute("UPDATE links SET read = 1 WHERE url = ?", (ROWS[0][0],))
connection.commit()

for title, read in connection.execute(
        "SELECT title, read FROM links ORDER BY title"):
    print(f"{'read  ' if read else 'unread'}  {title}")

(unread,) = connection.execute(
    "SELECT COUNT(*) FROM links WHERE read = 0").fetchone()
print("unread count:", unread)
Output
read    Descriptors explained
unread  Reading a signature
unread count: 1
The same data, in a database, in fifteen lines.

The interface is the thin part

With the core built and tested, the interface becomes almost boring — which is the sign you did it in the right order. Whether it is a command-line tool or a Flask app, its only jobs are to collect input, call your functions, and show the result.

Example · app.py
from flask import Flask, redirect, render_template, request, url_for

from reading_list.core import add_link, all_links, mark_read, summary
from reading_list.storage import connect

app = Flask(__name__)

@app.route("/")
def index():
    links = all_links(connect())
    return render_template("index.html", links=links, summary=summary(links))

@app.route("/add", methods=["POST"])
def add():
    try:
        add_link(connect(), request.form["url"], request.form["title"])
    except ValueError as error:
        return render_template("index.html", links=all_links(connect()),
                               summary="", error=str(error)), 400
    return redirect(url_for("index"))
Output
# A Flask app cannot run in the browser sandbox. Locally:
#   flask --app app run
#   * Running on http://127.0.0.1:5000
Every route is three lines: read the input, call the core, render the result.

Five days, and what each one ends with

Each day ends with something that runs. That constraint is what stops day four being the first time you find out nothing works together.

Example
Day 1  Setup            a folder, a virtual environment, git init, one commit.
                        Ends with: an empty program that runs and prints its name.

Day 2  The core         the plain functions that hold your logic.
                        Ends with: those functions callable from a Python prompt.

Day 3  Storage + input  sqlite3 behind the core, and the thinnest interface.
                        Ends with: data that survives the program exiting.

Day 4  Tests + errors   pytest over the core, and a clear message per bad input.
                        Ends with: a green test run and no traceback for a typo.

Day 5  Ship it          README, install steps, sample data, real output pasted in.
                        Ends with: someone else can clone it and run it.
A five-session plan. Sessions, not calendar days - three a week is fine.

Commit as you go

Version control is not paperwork here, it is the ability to try something and get back. Commit at the end of every session with a message that says what changed, and push somewhere — a project that exists only on one laptop is one spilled coffee from never having existed.

Example
git init
git add .
git commit -m "Reading list: core add/list/mark-read functions"

# after each session
git add -A
git commit -m "Store links in SQLite instead of a list"

# once, then git push on its own from then on
git remote add origin [email protected]:you/reading-list.git
git push -u origin main
The whole git vocabulary a capstone needs.

Shipping means someone else can run it

Here is the bar, and it is lower than “finished”: a person who has never seen your project can clone it and run it without asking you anything.

Which comes down to a short list: a README that says what it does and how to install it, an install path that works from a clean checkout, sample data if it needs data, a clear message rather than a traceback when input is wrong, and no secrets in the repository. Unit 15's portfolio lesson has the README template; this is the checklist that says whether you are done.

Score your own project honestly. blocking() returns everything still stopping someone else from using it, sorted; shippable() is true only when nothing is outstanding. Build the second on top of the first, so they can never disagree.

# "Shipped" means someone else can run it. That is the whole bar.
CHECKS = {
    "runs from a fresh clone": True,
    "README says what it does": True,
    "README says how to install it": True,
    "one test for the core behavior": True,
    "a clear message when the input is wrong": False,
    "no secrets in the repository": True,
}

def blocking(checks):
    """Everything still stopping someone else from using this."""
    return []      # TODO: the items whose value is False, sorted

def shippable(checks):
    """Shippable means nothing is outstanding."""
    return False   # TODO: build this on top of blocking()

print("outstanding:", blocking(CHECKS))
print("shippable:  ", shippable(CHECKS))

CHECKS["a clear message when the input is wrong"] = True
print("after fixing that:", shippable(CHECKS))

Common mistake: Adding features before the core works

Why it happens:

The new feature is more interesting than finishing the boring one.

How to fix it:

Get the three core features working end to end first, even crudely. Everything else is an idea for version 0.2, and some of those ideas will change once 0.1 exists.

Common mistake: Building the interface first

Why it happens:

It is the visible part, so it feels like the most progress.

How to fix it:

Write the logic as plain functions and test them from a Python prompt. Then the interface is a thin translation layer, and you can change your mind about whether it is a web page or a command.

Common mistake: Keeping the project only on one computer

Why it happens:

Pushing feels like something you do when it is presentable.

How to fix it:

Push on day one, when there is nothing to be embarrassed about. Private repositories are free, and a project only on one laptop is one accident from gone.

Common mistake: No instructions for anyone else

Why it happens:

You know how to run it, so the gap is invisible from where you are standing.

How to fix it:

Write the install and run commands, then follow them yourself from a fresh clone in a new folder. You will find two steps you had forgotten you ever did.

Common mistake: Waiting for it to be good before showing anyone

Why it happens:

You can see every rough edge and assume everyone else will lead with those too.

How to fix it:

Ship version 0.1 with a “what I would add next” section. Naming the limits yourself reads as judgment, and nothing is ever finished anyway.

What is the smallest useful version?

Why write the logic as plain functions before any interface?

What should each of the five days end with?

A feature is taking far longer than the whole day you allowed it. What now?

Mini exercise (hard)

Turn a feature list into a five-day plan. schedule(features, hours_per_day, days=5) returns (day, feature, hours) for everything that fits, filling each day up to hours_per_day before moving to the next, in the order given. A feature larger than a whole day fits nowhere, so it is skipped. cut() then reports what did not make it.

Try it yourself. Complete the snippet and hit Run; it executes in your browser, no setup required.

FEATURES = [
    ("save a link with its title", 3),
    ("list what I saved", 2),
    ("mark one as read", 2),
    ("tag filtering", 5),
    ("weekly email digest", 8),
]

def schedule(features, hours_per_day, days=5):
    """(day, feature, hours) for everything that fits, in order."""
    return []      # TODO: fill each day up to hours_per_day, then move on
                   # TODO: a feature bigger than one day goes nowhere - skip it

def cut(features, hours_per_day, days=5):
    """The features that did not fit at all."""
    return []      # TODO: build this from schedule()

plan = schedule(FEATURES, 4)
for day, feature, hours in plan:
    print(f"day {day}  {hours}h  {feature}")
print("cut:", cut(FEATURES, 4))

What to learn next

You cut a wish list to the smallest useful version, built the logic as plain functions you could test without an interface, put SQLite behind it, kept the Flask layer down to three lines a route, and scored the project against the only bar that matters — that someone else can clone it and run it. The five-day plan is there whenever you are ready to follow it.

Once it exists, it becomes the thing you talk about. Careers, Interviews, and Whether Certificates Matter is the honest version of that conversation: what interviews actually test, how to describe a decision, and what a certificate does and does not do.