Software craft The unwritten rules

How many concepts is too many

A list of core types is the designer's count. What a newcomer learns is what the first program makes them write.

On this page · 7 sections

When I designed a realtime media framework, my first design had ten core types. Is ten too many?

As asked, that can’t be answered. Framework debates are full of concept counts, from “it only has four core concepts” to “it has too many abstractions”, and the people making them are rarely counting the same thing. A list of core types is the designer’s count. What a newcomer has to learn is something else: what the smallest real program makes them write. I’ll count that for real examples from FastAPI, Django, pytest and LiveKit Agents, with a small script you can run on your own framework, then find the concepts the count can’t see, and end with two tests for when an extra concept is worth its cost.

The short version

  • A framework’s list of “core concepts” and what its first example makes you write are different counts, and they can move in opposite directions.
  • Count what the example makes you write, sorted by kind: the names you use, the functions you write for the framework to call, the settings you choose, the words you have to spell exactly. Don’t add the kinds together.
  • Some things you have to know aren’t written in the example at all. Find them by misspelling things one at a time, and see when, or whether, the framework notices.
  • Count per reader: the person using the framework and the person extending it write very different code.
  • An extra concept is worth it if it lets mistakes be caught sooner, or gives you a visible place to plug in your own code.

The cookbook and the first recipe#

Think of a cookbook that promises “just four techniques”. That’s the author’s count. A beginner’s count is different: open the first recipe and tally what you actually have to handle. The ingredients you have to buy. The steps where the recipe says “now you do this”. The amounts you have to set. The labels on jars you have to read correctly. And the rules nobody writes down, like “preheat the oven”, which you only learn when the cake doesn’t rise.

Frameworks are the same. Here’s LiveKit Agents, a Python framework for building voice agents, across two releases. Its README lists its core concepts, and its smallest complete example uses some number of the framework’s names:

LiveKit AgentsCore concepts in the READMEFramework names in its smallest example
1.0.13320
1.8.3417

The list grew from Agent, AgentSession and entrypoint to include AgentServer. The example shrank from 20 names to 17. The list describes how the framework is organised; the example is closer to what a newcomer has to hold in their head to get one thing working. They measure different things, so they can move in opposite directions, and an argument about “how many concepts” goes nowhere until you say which one you mean.

Counting what a user writes#

A count worth arguing about needs a rule you can rerun. Mine is a small script built on Python’s ast module, which parses source code into a tree the script can walk. It sorts what an example writes into four columns, the recipe’s four kinds of thing:

ColumnIn the recipeWhat the script counts
Namesingredientswhat the example imports from the framework, plus the attribute names it reads or calls on framework objects (app.get, session.start). Each attribute name counts once, so a.start and b.start are one
Hookssteps you do when toldfunctions the user writes for the framework to call: a decorated function, a method on a subclass of a framework class, a function passed into a framework call, or a function the framework finds by its name
Optionsamounts you setkeyword arguments passed to framework calls, and the string keys of dictionaries inside them
Stringslabels you must read rightshort string constants with no spaces inside a framework call’s arguments. In deepgram.STT(model="nova-3"), model is an option and "nova-3" is a string

Two more columns come later, for frameworks that read things by name: settings, and import paths. The output always shows all six, and the file’s line count.

Each column is a different kind of thing to learn, so they aren’t added up. If you need one number to quote, quote names, because every example has some; but compare the columns side by side. And names are only a proxy for effort: app.post adds little once you’ve met app.get, while FastAPI’s Depends, its way of declaring what a handler needs, brings a whole idea with it.

The core of the script is one walk over the tree:

python
count_concepts.py
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and from_framework(node.module):
result.imports.update(a.name for a in node.names)
elif isinstance(node, ast.Import):
result.imports.update(a.asname or a.name for a in node.names if from_framework(a.name))
elif isinstance(node, ast.Attribute) and is_fw(node.value):
if isinstance(node.value, ast.Attribute) and node.value.attr in string_namespaces:
result.strings.add(node.attr) # e.g. pytest.mark.<anything>: a name the project makes up
else:
result.members.add(node.attr)
... # hooks, options and strings from calls; import paths from every string

Abridged: hooks come from function and class definitions further down, and options and strings from calls.

ast.walk visits every node in the parsed file. An import or from ... import of the framework (from_framework checks the module name) adds to the names it imports. An attribute read on a framework object, like app.get, adds a member name. A variable counts as a framework object if it came from a framework import, was assigned from one, is annotated with a framework type, or is a parameter of a hook found by its prefix, which is the test is_fw applies. The one exception is a namespace like pytest.mark, which accepts any name you make up: that’s a string you have to spell right, not a name you learn.

Here’s what it makes of the example in FastAPI’s README, an app with two routes:

The FastAPI README example: an app created from FastAPI, a GET route on slash handled by read_root, and a GET route on /items/{item_id} handled by read_item, which takes item_id as an int and q as an optional string. What the counter sees is underlined: FastAPI and get as names, read_root and read_item as hooks, and the two route strings. Four numbered notes point at implicit rules it can't see. The item_id in the route must match the parameter's name. The int annotation makes FastAPI convert and validate the value. q is a query parameter because it's a singular type not in the path, and optional because it has a default. And fastapi dev looks for a file called main.py, or app.py or api.py, and picks up the FastAPI app in it.
Underlined: what the counter sees. Numbered: four rules it can’t.
text
python3 count_concepts.py fastapi main.py
main.py: 2 names (1 imported, 1 members), 2 hooks, 0 options, 2 strings, 0 settings, 0 import paths, 8 lines
imports: FastAPI
members: get
hooks: read_item, read_root
options: -
strings: /, /items/{item_id}
settings: -
import_paths: -

Two names, two hooks and two strings: a fair count of what the file says. But a newcomer needs four more things to write it, and none of them is in the file:

  1. FastAPI matches the route’s {item_id} to the path parameter of the same name.
  2. It converts and validates the value using the int annotation, and answers bad input with an HTTP 422 error.
  3. It treats any other parameter of a simple type as a query parameter, from the URL’s ?q=.... A parameter typed as a Pydantic model becomes the request body instead.
  4. Its command-line tool looks for a file called main.py (or app.py, api.py) and picks up the FastAPI app in it.

These are the recipe’s “preheat the oven”: implicit rules. Not a name, not a hook, not an option, not a string, and this counter can’t see them.

Where a concept lives decides when a mistake shows up#

Implicit rules are one of several places a concept can live outside the names a counter sees. Settings the framework reads by name are another, and so are strings it looks up later. Each place fails differently when you get it wrong, and the quickest way to find out how is to misspell one concept at a time and see when the framework notices. In the recipe, it’s the difference between a misspelled ingredient the shop can’t find and a mislabelled jar you only notice when you taste the cake.

A second script, typo_checks.py, does that against Django 6.1.1, pytest 9.1.1 and FastAPI 0.142.1, in fresh projects (the Django ones generated by Django’s own startproject):

text
python3 typo_checks.py
Django, a typo inside a middleware path:
manage.py check: System check identified no issues (0 silenced).
loading the app, as the server does: ImportError: Module "django.middleware.common" does not define a "CommonMiddlewar" attribute/class
Django, a typo in a setting's name:
manage.py check: System check identified no issues (0 silenced).
TIME_ZONE = America/Chicago
pytest, a typo in a hook's name:
pluggy._manager.PluginValidationError: unknown hook 'pytest_colection_modifyitems'; no tests ran in 0.00s
pytest, a typo in the hook prefix:
1 passed in 0.00s
pytest, a typo in a marker's name:
PytestUnknownMarkWarning: Unknown pytest.mark.slwo; 1 passed, 1 warning in 0.00s
FastAPI, a parameter name that doesn't match the path:
app built; GET /items/5 -> 422 missing ['query', 'itemid']

The last line joins two of FastAPI’s implicit rules from above. The route says {item_id} but the function says itemid, so the path parameter goes unmatched, and itemid is treated as a required query parameter instead, which the request didn’t send: missing ['query', 'itemid'].

Here are all of them sorted from caught earliest to never caught, plus two LiveKit cases. Those two aren’t in the script, since they’d need LiveKit’s service; they follow from Python’s import rules, the argument’s type and the client’s source, as the LiveKit section below explains:

Eight misspellings arranged from earlier to later by when they're caught. Before it runs: a misspelled plugin import, which a type checker flags and Python rejects at import. When it loads: a typo inside a Django middleware path, which passes manage.py check but stops the app from loading, and a misspelled pytest hook name, which stops pytest before any test runs. When it's used: a misspelled provider in a model string, accepted when the client is built and caught when LiveKit's service gets it, and a FastAPI parameter name that doesn't match the path, which fails with 422 on the first request. Ignored or warned: a misspelled Django setting name, which passes the check while the default stays in effect; a misspelled pytest hook prefix, which is silently ignored; and a misspelled marker, which only earns a warning while the test runs.
The same kind of mistake, a typo, is caught anywhere from before the program runs to never.

Django: settings read by name#

Django’s generated settings.py imports nothing from Django and calls nothing of Django’s, so it has no names at all. Tell the counter that Django reads module-level capitalised names as settings, and the summary line reads:

text
python3 count_concepts.py django settings.py --settings
settings.py: 0 names (0 imported, 0 members), 0 hooks, 0 options, 0 strings, 17 settings, 24 import paths, 76 lines

That’s 16 settings Django reads by name, plus a BASE_DIR helper, and 24 dotted import paths, strings like 'django.middleware.common.CommonMiddleware' that Django imports for you later, some when the app starts and some when a feature is first used.

Where the counter's rules miss

The script only counts strings with three or more dotted parts as import paths, so it misses ROOT_URLCONF = 'mysite.urls', which has two; the real count is 25. And with --string-namespace mark it counts every pytest.mark.<name> as a made-up string, including pytest.mark.skip, which is built in, so the conftest’s 4 strings include one that isn’t made up. The README lists the rest of its limits.

Both get past manage.py check. A typo inside a middleware path gets past manage.py check and stops the app when the server loads it. A typo in a setting’s name gets past everything: Django never asks for TIME_ZONES, so the site runs on Django’s built-in default, America/Chicago, not the 'Europe/Paris' you wrote.

pytest: hooks found by name, and checked#

pytest finds hooks by name too, and checks them. In conftest.py, pytest’s per-directory plugin file, a function whose name starts with pytest_ has to be a hook pytest knows, so a misspelled one stops pytest before any test runs. Misspell the prefix instead and the function is silently ignored. So the reserved prefix is what makes the check possible.

A marker is a label you attach to a test with @pytest.mark.<name>, and pytest.mark accepts any name, so @pytest.mark.slwo only earns a warning and the test runs, unless you turn on --strict-markers. That’s why the script counts a marker as a string.

LiveKit: provider names that moved into strings#

Strings are also where LiveKit’s provider names went between the two releases. The 1.0.13 example imported three provider plugins, packages wrapping one vendor’s models: Deepgram for speech-to-text, OpenAI for the language model and text-to-speech, and Silero for detecting speech. 1.8.3 names its providers inside strings instead, and some of them changed:

python
# livekit-agents 1.0.13
from livekit.plugins import deepgram, openai, silero
...
stt=deepgram.STT(model="nova-3"),
# livekit-agents 1.8.3
stt=inference.STT("deepgram/nova-3", language="multi"),

Three plugin imports and a load() call became one inference, the client for LiveKit’s hosted model service. That accounts for the whole drop from 20 names to 17; the other change, to the entrypoint, swapped two names for two others. The speech-to-text provider moved into a string that was already there, "nova-3" becoming "deepgram/nova-3", and the strings column rose from 3 to 5.

The rest of what changed between the two LiveKit examples

The entrypoint’s two names, WorkerOptions and connect, became AgentServer and rtc_session. The language model and text-to-speech switched providers and are named in strings too, and the speech detector’s provider became an unnamed default. The strings column rose because the text-to-speech model is now named and there’s a new language option. The provider keys changed with the move: the 1.0.13 README asks for Deepgram and OpenAI keys, and 1.8.3 only for LiveKit’s own credentials, because the example now goes through LiveKit’s gateway. Those environment variables are concepts read by name, too.

What the move changes is when a misspelled provider is caught. deepgramm in an import fails as soon as Python runs the line, and a type checker flags it before that. "deepgramm/nova-3" gets further. The model argument is typed with a Literal list of known names, a type that spells out the exact allowed values, so an editor can suggest them. But the argument also accepts any str, so the type checker lets a typo through, and it’s accepted when inference.STT is built: nothing in the client checks the provider, so the earliest it can be caught is when the request reaches LiveKit’s service.

Which reader pays#

The same framework can ask very different things of different people. pytest’s documentation has an example that adds a --runslow option and skips tests marked slow unless you pass it. A test author writes this:

python
test_module.py
import pytest
def test_func_fast():
pass
@pytest.mark.slow
def test_func_slow():
pass

The person extending pytest writes the conftest.py that makes the marker mean something:

python
conftest.py
import pytest
def pytest_addoption(parser):
parser.addoption(
"--runslow", action="store_true", default=False, help="run slow tests"
)
def pytest_configure(config):
config.addinivalue_line("markers", "slow: mark test as slow to run")
def pytest_collection_modifyitems(config, items):
if config.getoption("--runslow"):
# --runslow given in cli: do not skip slow tests
return
skip_slow = pytest.mark.skip(reason="need --runslow option to run")
for item in items:
if "slow" in item.keywords:
item.add_marker(skip_slow)

The test author writes two test functions and a marker. The plugin author writes three hooks pytest calls, pytest_addoption, pytest_configure and pytest_collection_modifyitems, and uses the objects pytest hands them:

FileWritten byCounted withNamesHooksOptionsStrings
test_module.pya test author--hook-prefix test_ --string-namespace mark2201
conftest.pya plugin author--hook-prefix pytest_ --string-namespace mark7344

--hook-prefix pytest_ tells the counter that pytest calls functions with that prefix, so they’re hooks, and the arguments pytest passes them, parser, config and items, are framework objects. Without either flag, the script finds no hooks in conftest.py and only 3 names, pytest, mark and skip. In the cookbook’s terms, the test author is a cook following a recipe, and the plugin author is writing a new chapter of the book.

One number for “pytest” would mix those two jobs. So compare the same reader’s smallest real task in each framework: an app author’s quickstart with an app author’s quickstart, a plugin with a plugin.

Now you’ve met every flag, try the counter on each example. Start with the pytest conftest and switch between No flags and With the framework’s flags, then compare the two LiveKit releases one after the other:

What the counter seeslive lab

This lab runs a real call in your browser, so it needs JavaScript.

And misspell things yourself. Try the Django setting’s name first, and see whether anything complains:

Misspell itlive lab

This lab runs a real call in your browser, so it needs JavaScript.

When more concepts are worth it#

A lower count isn’t automatically better. Extra concepts earn their place in two ways.

The first is an earlier check. Every name in LiveKit’s 1.0.13 example is something Python checks when the module loads (the imports) or when the line runs (the attributes), and a type checker catches all of them before anything runs. A count that rewards moving concepts into strings and settings rewards moving mistakes later.

The second is a seam you can see: a place to put your own code, with its position visible. Django’s MIDDLEWARE list is one. Each entry wraps every request. To add your own, you write one middleware and put its path in the list where you want it to run, and the list is the order: Django “applies middleware in the order it’s defined in MIDDLEWARE, top-down”, as its middleware docs put it. You can put a step anywhere, reorder steps, and read the whole order in one place.

LiveKit’s llm_node is the hook version: an agent overrides it to change how the language model is called. It’s cheaper, one hook and no settings, but you can’t add or move a step, and where it runs is in LiveKit’s docs, not your code. Both are seams; the list is the one you can see. And the list shows up in the settings and import-path columns and the hook in the hooks column, so a names-only count sees neither. Compare frameworks across all the columns, or the one whose pipeline is hidden will always look smaller.

Count your own quickstart#

So, is ten too many? Back to the cookbook: it depends which of the ten end up in the beginner’s first recipe, and which only the person writing a new chapter ever meets. A type that only people writing new steps see costs an app author nothing. Ten names in the first recipe would be a lot; ten types spread across columns and readers might not be. To find out for your framework, run the script on its smallest real example, adding --hook-prefix, --settings or --string-namespace wherever the framework finds things by name. Then:

  1. Find the implicit rules. They aren’t a column. Read the docs for everything that happens “automatically”, then misspell each one and note when it’s caught: before it runs, when it loads, when it’s used, only with a warning, or never.
  2. Fix the silent ones first. A rule that fails silently, like a misspelled setting name, needs a check or a reserved prefix more than it needs to be counted down.
  3. Weigh each extra concept. Keep one that moves a check earlier or gives users a seam they can see; question one that does neither.
  4. Split by reader. Count the app author’s quickstart and the extension author’s smallest plugin separately. If you’re choosing between frameworks, compare the same reader’s task in each, across all the columns.

The demo#

The scripts are in concept-count/. The counter needs only Python 3.9 or later; the typo checks need Python 3.12 or later, and install the pinned Django, pytest and FastAPI.

Shell
Terminal window
cd mainlog-code/concept-count
python3 count_concepts.py yourframework quickstart.py # your own example
python3 run_comparison.py --detail # the six examples above, every counted item
python3 -m pip install -r requirements.txt
python3 typo_checks.py # misspell one concept at a time

run_comparison.py downloads each example from GitHub at a pinned commit, so the numbers here don’t move when a README changes. The README lists the counter’s limits: it tracks framework objects by syntax, not by running the code, so objects that reach an example another way are missed.

Share this article

Related posts

3 items
  • Oct 21, 2026 12 min read Software craft

    The one-sentence API design test

    Six AI agents each designed one extension to my framework, and each design held up on its own. Side by side they disagreed about names every user touches. One question sorted them out, and writing down the rules my existing code already followed made the answer countable.

    The unwritten rulesapi-designai-agentspython
  • Sep 15, 2026 11 min read Software craft

    Green on my laptop, red in CI

    A pytest plugin passed every test from the source checkout and failed twice in CI, because only the installed wheel told pytest the plugin existed.

    The unwritten rulespythontesting
  • Sep 4, 2026 18 min read Software craft

    What plugin discovery actually checks

    A plugin you can see in pip list comes back “not found”. Python hosts turn installed entry points into loaded code in four checks, and the order of those checks decides whether a plugin's code runs before anyone asks if it fits, and whether the error your code catches says why.

    The unwritten rulespythonpackagingplugins
  • Software craft

    The one-sentence API design test

    Six AI agents each designed one extension to my framework, and each design held up on its own. Side by side they disagreed about names every user touches. One question sorted them out, and writing down the rules my existing code already followed made the answer countable.

    api-designai-agents
    Oct 2026
  • Software craft

    Green on my laptop, red in CI

    A pytest plugin passed every test from the source checkout and failed twice in CI, because only the installed wheel told pytest the plugin existed.

    pythontesting
    Sep 2026
  • Software craft

    What plugin discovery actually checks

    A plugin you can see in pip list comes back “not found”. Python hosts turn installed entry points into loaded code in four checks, and the order of those checks decides whether a plugin's code runs before anyone asks if it fits, and whether the error your code catches says why.

    pythonpackaging
    Sep 2026

Comments