Skip to content

How to Go from Syntax to Shipping: A Practical Guide

Most people learn syntax and stall before shipping. This guide breaks the gap into five layers — Idea, Scope, Stack, Build, Ship — with a three-feature MVP rule, an argument for boring tech, the real backend code, and a ten-item checklist.

What you'll learn: why syntax and shipping are different skills, the five layers every shipped project passes through, how to cut a project down to three must-haves, how to pick a boring stack, the plan → prompt → run → test → commit loop for AI-assisted building, and a shipping checklist you can reuse.

You can write a for loop, wire up a route, and push a commit. And yet the projects folder is a graveyard of half-built things that never reached a user.

That is not a knowledge problem. It is a shipping problem.

This guide is about closing the gap between the two, using one concrete example: a small Django service of ours that moves uploaded media off the server's local disk and onto cloud object storage. Six commits, about 90 lines, and it is live. It stayed deliberately small, because small is what actually ships.


1. The gap: syntax vs shipping

Syntax is the ability to make a computer do what you described. Shipping is the ability to make a stranger's problem go away and keep it away after you close your laptop.

Those are different skills, and almost every tutorial teaches the first one. Here is what the second one actually requires:

SyntaxShipping
"My code runs on my machine""My code runs on a machine that isn't mine"
"I understand how this works""I can explain why this exists in one sentence"
"I finished the feature""Someone else used the feature and nothing broke"
"The tests pass locally""There is a way back if this goes wrong"
Learning by followingLearning by deciding

The uncomfortable part: you cannot practice shipping by reading. You practice it by finishing small things and putting them in front of other people.

2. Why most learners get stuck

Three failure modes explain most stalled projects.

Tutorial hell

Tutorials remove every decision from the process. The instructor already chose the stack, the folder structure, the scope, and the stopping point. So you accumulate syntax and zero decision-making practice — and the moment you face an empty folder, you freeze, then reach for another tutorial.

The exit is to keep the tutorial's technique and replace its decisions with yours. Build something the tutorial doesn't cover, in the stack the tutorial used.

No scope

"A social platform for photographers" is not a project. It is a category. With no boundary, every session ends with a longer to-do list than it started with, because building reveals possibilities faster than you can build them.

Scope is not a constraint you accept after planning. It is the planning.

No deployment

This is the silent one. If you have never deployed, your mental model of "done" is wrong in ways you cannot detect — because deployment is where environment variables, file permissions, storage, migrations, and network boundaries stop being theoretical.

Our storage app is a perfect illustration. The problem was never "the code doesn't work locally".

The problem was that containers get replaced on every deploy, and media files written to local disk disappear with them. Nobody learns that from a tutorial, because a tutorial's filesystem never gets replaced.

3. The 5 layers of shipping

Every shipped project, from a weekend script to a production CMS, passes through the same five layers:

LayerThe question it answersWhere it usually goes wrong
1. IdeaWhat problem, for whom, so they can do what?Replaced by a feature wish list
2. ScopeWhat is the smallest version that helps?Left undefined until it is too late
3. StackWhat boring tools will carry this?Chosen for novelty or résumé value
4. BuildHow do I make progress in small verified steps?Long unverified stretches of work
5. ShipHow does this reach someone, and how do I undo it?Skipped entirely

The layers matter in order. Each one narrows the next. And notice the arrow of effort: most people spend 90% of their energy in layer 4 and almost none in layers 1, 2, and 5, which is exactly why projects stall.

4. Layer 1: Idea → one sentence

Write your idea as one sentence, in this shape:

I want to build ___ for ___ so they can ___.

Three blanks, three commitments. The first names the thing, the second names the person, and the third names the outcome. If you cannot fill the third blank, you do not have a project yet — you have a technology preference.

The sentence for our storage app:

I want to build durable media storage for CMS editors so they can upload images once and still see them after every deploy.

Compare that with the version most of us start with — "add S3 support" — which names a vendor instead of an outcome. The first sentence survives a provider change; the second one dies the moment finance prefers a different cloud.

A useful test: read your sentence and ask whether a non-technical colleague would understand the benefit. If not, rewrite the third blank.

5. Layer 2: Scope → MVP in 3 features

Now cut. Not to "all the features you can imagine, prioritised" — literally to three. Sort everything else into three buckets.

BucketRuleStorage app example
Must-haveWithout this, nothing worksOne storage path that reads all of its settings from a single config block, and becomes the default for every upload
Nice-to-haveThe MVP works without it, but someone will askA second cloud provider alongside the first; extra signature and address options in the config
LaterReal work, wrong momentMoving the files that are already on disk; per-provider signed link policies; image transformation rules

The whole app is three files:

apps/cloud_storage/
├── __init__.py           # re-export the class so settings can point at it
├── apps.py               # AppConfig
└── storage_backends.py   # the backend itself

That is the surface area. Anything in the "later" bucket would have added files to that tree, and every added file is another thing to keep alive.

Two rules make this work.

Three must-haves, maximum. If you list seven, you have written a roadmap and called it an MVP.

The "later" bucket is a promise, written down. The point is not to forget these items; it is to stop them from silently inflating today's work. Our app genuinely did add a second cloud provider — two weeks later, as a handful of config lines, because the interface was already correct.

Write the three must-haves as acceptance criteria you can verify: "upload an image through the admin, and the rendered URL points at the bucket domain." The bucket is the cloud folder that holds the files; the domain is what serves them to a browser. Not "storage works".

6. Layer 3: Stack → pick boring tech

Boring tech is technology with a decade of Stack Overflow answers, a maintainer who has a track record, and a thousand people who have already hit your bug.

Why boring tech wins

Your bugs are already solved. Every problem you hit with a mature storage library has been hit, filed, and answered. The problems you hit with the library you wrote last weekend have been hit by exactly one person: you.

Boring tech has boring failure modes. File storage that breaks is a file that didn't upload. File storage you built yourself breaks with signature mismatches that only appear for files above 50 MB on a Tuesday.

It is what AI assistants know best. This is the modern argument, and it is the strongest one. When you ask an assistant for help with a mature library, it is retrieving a well-trodden path. When you ask it to extend your bespoke abstraction, it is guessing — fluently, confidently, and sometimes wrongly.

You can afford to spend your novelty budget elsewhere. You have exactly one interesting idea per project. Spend it on the problem, not on your HTTP signing code.

For this app, the boring stack was Django, Wagtail, and the long-standing django-storages package that has been doing exactly this job for years. Three providers, one storage class, no provider-specific code anywhere.

Boring also meant one boring interface: a single settings block, five required keys, and a few optional ones. Nothing else in the project needs to know which cloud is behind it.

KeyRequiredDefault
PROVIDERyes— (s3 / aliyun / tencent)
ACCESS_KEY_ID, ACCESS_KEY_SECRETyes—
BUCKET_NAMEyes—
REGIONyes—
ENDPOINTnoderived from provider + region
PUBLIC_URLno— (the domain that serves the files)
DEFAULT_ACLnopublic-read
FILE_OVERWRITEnofalse
SIGNATURE_VERSION, ADDRESSING_STYLEnoprovider default

In practice that block looks like this, and one line of wiring makes it the default for every upload:

CLOUD_STORAGE = {
    "PROVIDER": "tencent",
    "ACCESS_KEY_ID": "<key-id>",
    "ACCESS_KEY_SECRET": "<key-secret>",
    "BUCKET_NAME": "my-bucket-<appid>",   # COS buckets carry the APPID
    "REGION": "<region>",
    "ENDPOINT": "https://cos.<region>.myqcloud.com",
    "DEFAULT_ACL": "public-read",
    "PUBLIC_URL": "staging.example.com/media",
    "SIGNATURE_VERSION": "s3v4",
    "ADDRESSING_STYLE": "virtual",
}

STORAGES = {
    "default": {"BACKEND": "apps.cloud_storage.CloudStorage"},
    "staticfiles": {
        "BACKEND": "django.contrib.staticfiles.storage.ManifestStaticFilesStorage",
    },
}

Switching clouds means editing values in that block. It does not mean touching settings in twenty other places, because there is only this one.

The boring choice also came with a boring rule we wrote into the plan: no new environment variables, no provider-specific libraries, and no changes to the code that already uploads files. Constraints like that are what turn a stack into an architecture.

7. Layer 4: Build → AI pair programming loop

With an assistant in the loop, the classic advice ("commit often", "keep the feedback loop short") becomes a specific five-beat rhythm:

Plan → Prompt → Run → Test → Commit.

Plan

Decide the interface before you ask for code: file paths, class name, config keys, required vs optional fields, and what must not change. If you cannot describe the interface, you are not ready to prompt — and no amount of prompting will fix that.

Prompt

A prompt is a spec. Include the contract, the negative space ("no env vars, no provider SDKs, don't touch call sites"), and the exact command you will use to verify the result. Vague prompts produce code you have to reject, which costs more time than writing the spec did.

Run

One change, then a real signal. The cheap check comes first — ask Django which storage it will use, and confirm it is the new one:

python manage.py shell -c "from django.core.files.storage import default_storage; print(default_storage.__class__)"
# <class 'apps.cloud_storage.storage_backends.CloudStorage'>

That only proves the wiring is in place. The check that proves something is a file going down the real path — upload an image through the admin and look at the URL the page renders. That one step exercises the model field, the storage, the bucket policy, and the public domain, in the order a real user hits them.

Test

Ask for the failing test before the fix, and keep tests in the layers you can run without a network:

  • The settings translation: every config value lands where the library expects it, and a missing bucket name fails with a clear message instead of a stack trace three layers deep.
  • The upload helper: text is encoded the same way every time, and the function hands back a usable URL.
  • The real path: an upload through the admin produces a link that actually opens.

The tests that did not need a bucket looked like this — a stand-in storage, and assertions about what the helper did with it:

from unittest.mock import MagicMock, patch

from django.test import TestCase

from your_app.tasks import upload_to_oss


class UploadToOSSTest(TestCase):
    """upload_to_oss uploads through the lazy singleton storage."""

    def setUp(self):
        self.mock_storage = MagicMock()
        self.mock_storage.save.return_value = "reports/123/file.md"
        self.mock_storage.url.return_value = "https://cdn.example.com/reports/123/file.md"
        self.patcher = patch(
            "your_app.tasks._get_default_storage",
            return_value=self.mock_storage,
        )
        self.patcher.start()

    def tearDown(self):
        self.patcher.stop()

    def test_save_called_with_correct_key(self):
        upload_to_oss("test content", "reports/123/file.md")
        args, _ = self.mock_storage.save.call_args
        self.assertEqual(args[0], "reports/123/file.md")

    def test_string_content_encoded_to_bytes(self):
        upload_to_oss("你好世界", "reports/123/file.md")
        args, _ = self.mock_storage.save.call_args
        self.assertEqual(args[1].read(), "你好世界".encode("utf-8"))

Our first tests for this app covered the middle layer with a stand-in storage — five assertions, milliseconds to run, and no bucket involved.

Commit

One verified change per commit, with a message that says why. The history of the storage app is the rhythm made visible:

DateWhat landedThe beat
May 31The first version: the storage class, the config wiring, the docsThe whole shape in one go, small enough to read
May 31Static files and media turned out to be two different problemsThe prerelease environment said so, loudly
Jun 8A second cloud providerThe "nice-to-have" arrived without touching anything else
Jun 9Extra provider options moved into the configThe same interface, more knobs
Jun 9Existing upload code folded into the new path, with testsClosing the other door into the same capability
Aug 31A large-file upload failed; the threshold was fixedA real failure, found by a real file

Six commits, three months, three providers, no rewrites. Notice that two of them are refactors that pulled existing code into the new seam. When an assistant writes a module, your job is partly to close the other doors into the same capability.

What the loop produced

Here is the backend, close to verbatim. It is short enough to read on one screen, which is the point:

from urllib.parse import urlparse

from boto3.s3.transfer import TransferConfig
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.utils.deconstruct import deconstructible
from storages.backends.s3 import S3Storage


ENDPOINT_TEMPLATES = {
    "s3": None,                                              # AWS keeps its default
    "aliyun": lambda region: f"oss-{region}.aliyuncs.com",
    "tencent": lambda region: f"cos.{region}.myqcloud.com",
}


def _build_endpoint_url(provider, region, endpoint):
    """Full S3-compatible endpoint URL, scheme included."""
    if endpoint:
        raw = endpoint.strip()
        if urlparse(raw).scheme:
            return raw.rstrip("/")
        return f"https://{raw}"
    template = ENDPOINT_TEMPLATES.get(provider)
    if callable(template):
        return f"https://{template(region)}"
    return None


@deconstructible
class CloudStorage(S3Storage):
    """S3-compatible storage for three providers, configured from one dict."""

    def __init__(self, **kwargs):
        config = getattr(settings, "CLOUD_STORAGE", None)
        if config:
            provider = config.get("PROVIDER", "").strip().lower()
            bucket_name = config.get("BUCKET_NAME", "")
            if not bucket_name:
                raise ImproperlyConfigured("CLOUD_STORAGE.BUCKET_NAME is required.")

            kwargs.setdefault("access_key", config.get("ACCESS_KEY_ID", ""))
            kwargs.setdefault("secret_key", config.get("ACCESS_KEY_SECRET", ""))
            kwargs.setdefault("bucket_name", bucket_name)
            kwargs.setdefault("region_name", config.get("REGION", "") or "us-east-1")
            kwargs.setdefault("default_acl", config.get("DEFAULT_ACL", "public-read"))
            kwargs.setdefault("file_overwrite", config.get("FILE_OVERWRITE", False))

            endpoint_url = _build_endpoint_url(
                provider, config.get("REGION", ""), config.get("ENDPOINT", "")
            )
            if endpoint_url:
                kwargs.setdefault("endpoint_url", endpoint_url)
            if config.get("PUBLIC_URL"):
                kwargs.setdefault("custom_domain", config["PUBLIC_URL"])

            # A public bucket should hand out plain URLs, not signed ones that expire.
            if config.get("DEFAULT_ACL", "public-read") == "public-read":
                kwargs.setdefault("querystring_auth", False)

            if config.get("SIGNATURE_VERSION"):
                kwargs.setdefault("signature_version", config["SIGNATURE_VERSION"])
            if config.get("ADDRESSING_STYLE"):
                kwargs.setdefault("addressing_style", config["ADDRESSING_STYLE"])

        # Under 50 MB goes up as one PUT: some providers reject multipart
        # parts that arrive without Content-Length.
        kwargs.setdefault(
            "transfer_config",
            TransferConfig(multipart_threshold=50 * 1024 * 1024, use_threads=False),
        )
        super().__init__(**{k: v for k, v in kwargs.items() if v is not None})

Four things in there carry the whole design:

  • @deconstructible — migrations can serialise the class, so switching storage never breaks a migration.
  • The public-read guard — links that do not expire, because the bucket is public anyway.
  • The TransferConfig line — the fix for the provider that rejected large multipart uploads. It exists because a real file failed, not because anyone predicted it.
  • ENDPOINT_TEMPLATES — the entire "provider abstraction" is three lines. That is why adding a second cloud later cost a config block and nothing else.

8. Layer 5: Ship → deploy, README, share

Shipping has three parts, and people routinely do only the first.

Deploy

Deploying means the thing runs somewhere other than your laptop, in front of a real network boundary. For this app it came down to one settings value: the new storage became the default for every upload in the live CMS, so media stopped landing on the server's disk and started landing in the bucket.

Two things made that safe to do on a Friday. The keys live in the environment's own config file — not in the code tree, not in the repository. And the way back is one line, because a deploy without a rollback is a bet:

STORAGES["default"]["BACKEND"] = "django.core.files.storage.FileSystemStorage"

No migration, no data to move, no redeploy of anything else.

Or: ship it as a package

A storage backend is library-shaped, so there is a second way to ship it: publish it, and let somebody else's deploy be your release. If what you built has a life outside your product, this is often the easier first ship — no server to keep alive, no config seam to mount, and a release checklist that fits on an index card.

[project]
name = "django-cloud-storage"
version = "0.1.0"
description = "S3-compatible Django storage backend for Aliyun OSS, Tencent COS and AWS S3."
requires-python = ">=3.11"
dependencies = ["Django>=5.2", "django-storages>=1.14", "boto3>=1.34"]
readme = "README.md"

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
uv build                                          # or: python -m build
uv publish --publish-url https://test.pypi.org/legacy/   # rehearse on TestPyPI first
uv publish                                        # then the real index

Two honest differences from shipping inside your own product. The audience is strangers: the README becomes the product, the version number becomes a promise, and support becomes a weekend activity. And rollback gets weaker — you can yank a release, but anyone who already pinned it keeps the bad version, so version numbers are the only safety rail you control:

uv add "django-cloud-storage==0.1.0"   # consumers pin, so a broken 0.1.1 cannot reach them

If you want the install ergonomics without the public audience, a private index or a git dependency gets you most of the way.

For this app, though, publishing was not the right ship. It is a storage backend for one CMS: one settings block, no users outside the project, and a release cadence that would only ever follow the CMS.

Publishing it would have made the code and the version number public in exchange for nothing we needed. Shipping it inside the product was the right call here; publishing is the right call when other people are the audience.

README (and the doc next to the code)

Write for the person who arrives in three months — probably you, with no memory of today's reasoning. Four short sections are enough:

  1. What problem this solves, in one sentence.
  2. What configuration it needs, as a field table.
  3. One working example per supported provider.
  4. How to verify it works, and how to turn it off.

Section 2 is the one people skip, and it is the one that saves the most time. Ours is a table: what each config key means, whether it is required, and what it defaults to.

That document lives next to the code, in the project repo.

Share

Post it somewhere with a link: a team channel, a Reddit thread, a short write-up. Sharing is not marketing — it is the only way to discover that your "obvious" setup step is impossible for someone else. The first person who tries your thing will find the gap between your README and your reality in about ninety seconds.

9. Shipping checklist

Copy this before you call anything finished.

#CheckDone when
1Idea sentenceYou can say it out loud without mentioning a library
2Three must-havesEach one is verifiable by hand in under a minute
3Boring stackEvery dependency has been around longer than your project
4Constraints written downThe "must not change" list exists in the prompt or the README
5Local runA single command starts it from a clean checkout
6Test that fails firstYou watched it fail before the fix landed
7DeployIt runs somewhere other than your laptop — or someone who is not you can install it
8DocsField table, one working example, verification commands
9RollbackYou can describe the undo in one sentence
10SharedA real person has the link and a way to complain

Ten items. If you cannot tick seven of them, you are not blocked — you are unscoped.

10. Common traps

Over-engineering. The signal is when you design for a scale you do not have: a plugin architecture with one plugin, an abstraction layer over an abstraction layer, a config system for three settings. The cure is to ask, "what breaks if I delete this?" If the answer is nothing today, it goes in the later bucket.

Endless refactoring. Refactoring feels like progress because the code objectively improves, but the user gets nothing. Our refactors landed on the same day as the feature they enabled — the June 9 refactor existed to make tests possible, not to feel productive. Rule of thumb: allowed to refactor inside a commit that also ships something, or when a real bug has exposed the design.

No deadline. Without a date, scope expands to fill all available time, and the project never reaches the moment where it can fail in public. Pick a date you can defend ("demo on Friday"), then cut scope rather than move the date. When the deadline is fixed and the scope is negotiable, the MVP becomes obvious.

Two more worth naming, because they show up in AI-assisted work specifically: accepting changes you did not read, and letting the assistant choose your config defaults. Defaults are where a module quietly changes behaviour — whether files get overwritten, who can read them, whether links expire. Read the constructor first, every time.

11. CTA

Start with the sentence. Right now, in a note, not in a repo:

I want to build ___ for ___ so they can ___.

Then list your three must-haves, pick the most boring stack that solves them, and put one of them on the internet this week — even if it is ugly, even if nobody uses it, even if the README is three lines long.

The habit you are building is not "writing more code". It is the loop: decide, cut, build small, verify, ship, repeat. The syntax was never the hard part; it was the part that came with instructions.

Related Reading