7.4 KiB
title, date
| title | date |
|---|---|
| Software Development Standards | 2026-10-08T19:18:00+04:00 |
These are the rules I write code by. They are based on Robert Martin's "Clean Code" and PEP 8 for Python. It's a living document, I extend it as I learn things the hard way.
The main idea is simple: code is read far more often than it is written. So I write it for the person who opens the file six months from now. Often that person is me.
0. No spec, no result
Any work starts with requirements, not with code.
- First I pin down what problem we're solving and how we'll know it's solved.
- Then the architecture: components, data, the boundaries between them.
- Only after that, code. If requirements change along the way, I update the spec first, then the code.
1. Code style: PEP 8 + my rules
PEP 8 is the baseline standard. My rules work on top of it, like the cascade in CSS: everything I haven't overridden is inherited from PEP 8 as is. There's one override.
Tabs instead of spaces. One level of nesting is one character. Everyone sees indentation at the width they set in their editor, and nothing changes in the file. I never mix spaces and tabs in one file, Python 3 won't allow it anyway.
Everything else follows PEP 8: line length, blank lines between functions and classes, import order, spaces around operators.
So I don't have to keep this in my head, a tool checks and fixes the style. I use ruff:
# pyproject.toml
[tool.ruff]
line-length = 100
[tool.ruff.format]
indent-style = "tab"
[tool.ruff.lint]
select = ["E", "F", "I", "N", "B", "UP"]
ignore = ["W191"] # W191 complains about tabs, for us it's a deliberate choice
IDE: JetBrains (PyCharm). The project settings have tabs enabled and ruff runs on save.
2. Names
A name should answer three questions: why it exists, what it does and how it's used. If a name needs a comment, it's a bad name.
- Variables and functions in
snake_case, classes inPascalCase, constants inUPPER_CASE. - Functions are named with a verb:
fetch_page,parse_price,send_report. Classes and variables with a noun:Browser,proxy_pool. - No abbreviations or single-letter names. Exceptions:
iin a short loop andefor an exception. - One concept, one word. If the project has
fetch, thenget,loadandretrievedon't show up next to it for the same thing. - No type encoding in names:
users, notusers_listorlst_users. - Boolean variables read like a question:
is_ready,has_proxy,can_retry.
3. Functions
- Small. A function should fit on the screen without scrolling. If it doesn't, it can be split.
- Do one thing. If a function can't be described in one sentence without the word "and", it's two functions.
- One level of abstraction. A function either orchestrates a process and calls other functions, or works with details. Not both at once.
- Few arguments. Ideally zero to two. Three or more is a reason to group them into a dataclass.
- No flag arguments.
render(page, True)is hard to read. Better two functions:render_fullandrender_preview. - No hidden side effects. If a function is called
check_session, it shouldn't create a new session along the way. - Command or query. A function either changes state or returns data. Not both.
4. Comments
The best comment is the one that wasn't needed because the code is clear as it is.
I write a comment when I need to explain why, not what:
# Cloudflare returns 403 on the first request without a cookie, so we always make two
response = session.get(url)
I don't write:
- comments that retell the code
- commented-out code. That's what git history is for
- a change log and authorship in the file header. That's git's job too
A docstring is required for a module's public functions and classes. For internal ones, it depends.
5. Error handling
- Exceptions instead of return codes. A function doesn't return
Noneor-1when something goes wrong, it raises an exception. - I catch specific exceptions, never a bare
except:orexcept Exception: pass. - My own exceptions for my domain:
ProxyBannedError,CaptchaError. They tell you what happened without reading the stack trace. - I don't return
Nonewhere a collection is expected. An empty list is better than aNonecheck at every call site. - I handle an error where I know what to do with it. If I don't know, I let it go up.
6. Classes and modules
- Small classes with a single responsibility. A class should have one reason to change. If a class talks to the network, parses HTML and writes to the database, that's three classes.
- High cohesion. A class's methods work with its fields. If a method doesn't touch
self, it most likely doesn't belong in this class. - Dependencies from outside. A class receives the browser, session or database client in its constructor instead of creating them itself. That makes it easy to test and swap.
- Law of Demeter. I talk only to immediate neighbors.
order.customer.address.cityis a sign that a method is needed. - Boundaries with third-party code. I wrap third-party libraries in my own thin layer. If the library has to be replaced, the change happens in one place.
7. Tests
- Tests are code too, and the same cleanliness requirements apply.
- A test checks one thing, and its name shows it:
test_returns_empty_list_when_page_has_no_items. - Every test has the same structure: arrange, act, assert.
- Tests are fast, independent of each other and give the same result on every run. No trips to the real internet in unit tests.
- I reproduce a bug with a test first, then fix it.
8. The Boy Scout rule
I leave code cleaner than I found it. There's no need to rewrite everything at once. It's enough to rename one unclear variable or extract one piece into a function every time I touch a file.
9. Git
- One commit, one logical change.
- Commit messages in the conventional commits format:
feat:,fix:,refactor:,docs:,chore:. - Code that doesn't pass the linter and tests doesn't get into the main branch.
- Secrets, keys and
.envnever get into the repository.
10. Packages
- Every package has a
README.md. It says why the package exists and what problem it solves. You open the folder and understand what's inside without reading the code. - The programming interface lives in an
apimodule. If other code uses the package, everything meant to be called from outside is collected inapi. The rest of the package's modules are internal: other code doesn't reach into them, and I can change them without worrying about the package's users. If no code calls the package, it doesn't needapi.
Django packages
A Django app gets this set of modules:
settings, always. The list of every setting the package understands, with defaults and documentation for each. Inside the package, settings are read only from here, not throughdjango.conf.settingsscattered across the code. To find out how to configure the package, one file is enough.urls, if the package has views. The package's routes; the project plugs them in withinclude().api, if other code calls the package. Following the rule above.
# reports/settings.py
from django.conf import settings
# How many days to keep generated reports. After that they are deleted.
REPORTS_TTL_DAYS = getattr(settings, "REPORTS_TTL_DAYS", 30)
# Address that report emails are sent from.
REPORTS_FROM_EMAIL = getattr(settings, "REPORTS_FROM_EMAIL", settings.DEFAULT_FROM_EMAIL)