The same condition twice. The second one can be searched for, changed in one place, and argued about.
if user['age'] < 13:
return False
# why 13? is it the same 13 as the one in moderation.py?
MINIMUM_AGE = 13 # COPPA: under-13s cannot hold an account
if user['age'] < MINIMUM_AGE:
return False
# And the thing you can now do:
# grep MINIMUM_AGE -> every place the rule is applied
- The name is a search term
Searching a codebase for 13 finds array indices, HTTP status codes and somebody’s test fixture. Searching for MINIMUM_AGE finds the rule, everywhere it is applied. That is most of the value — not readability in the moment, but the ability to find every consequence of a decision when the decision changes.
- Module level, not inside the function
A constant defined inside the function is invisible to everything else, so the next module that needs the same rule copies the number and the two drift. At module level it can be imported, referenced in a test, and changed once. It also stops being rebuilt on every call, which is irrelevant here and occasionally is not.
- A comment for why, a name for what
MINIMUM_AGE says what the number is; only a comment can say that it is a legal requirement rather than a product preference — and that is the difference between a number somebody may tune and one they must not. Names carry meaning, comments carry reasons.
Four numbers, four names, at module level above the function. Nothing else about the function changes — same order, same comparisons.
The constraint names them for you: MINIMUM_AGE, DAILY_POST_LIMIT, MINIMUM_REPUTATION and STRIKE_LIMIT. Capitals with underscores is the convention for a module-level constant.
Copy each comparison exactly and substitute only the number — two of them are < and two are >=, and the difference is deliberate. A comment above the age constant is worth adding, because it is the only one of the four that is not a product decision.
MINIMUM_AGE = ____ # legal requirement, not a product setting DAILY_POST_LIMIT = ____ MINIMUM_REPUTATION = ____ STRIKE_LIMIT = ____ def can_post(user): if user['age'] < ____: return False if user['posts_today'] >= DAILY_POST_LIMIT: return False if user['reputation'] < MINIMUM_REPUTATION: return False if user['strikes'] >= ____: return False return True
# A legal requirement, not a product setting — do not tune this one.
MINIMUM_AGE = 13
DAILY_POST_LIMIT = 20
MINIMUM_REPUTATION = 50
STRIKE_LIMIT = 3
def can_post(user):
"""Return whether this user is allowed to post."""
if user["age"] < MINIMUM_AGE:
return False
if user["posts_today"] >= DAILY_POST_LIMIT:
return False
if user["reputation"] < MINIMUM_REPUTATION:
return False
if user["strikes"] >= STRIKE_LIMIT:
return False
return True
Two are < and two are >=, because two of the numbers are minimums you must reach and two are limits you must not reach. Making them consistent would feel tidier and would change three of the boundary cases. In a refactor, the comparison operators are behaviour and not style.
MINIMUM_AGE rather than AGE, DAILY_POST_LIMIT rather than POSTS — the name says which side of the comparison the number sits on, so `age < MINIMUM_AGE` reads as a sentence and `age < AGE` reads as a typo. A well-named constant makes the comparison self-checking.
Nothing stops anybody assigning to MINIMUM_AGE at runtime; the capitals are a promise between people, and in practice they hold. If you need the promise enforced — a public API, a value other code must not monkey-patch — an Enum or a frozen dataclass will do it, at the cost of a lookup and some ceremony.
Not every literal is magic. 0 and 1 as starting points, 2 in a halving, 100 in a percentage, and the 60s in a time conversion are all clearer as themselves — naming them adds a hop for the reader and explains nothing. The test is whether the number is a decision somebody made: if it could plausibly have been a different number, name it. And once these values need to differ per environment or be changed without a deploy, they stop being constants and become configuration, which is a different mechanism with its own costs.
- Move the four values into a settings object passed in, and decide whether the function got better or merely more configurable.
- Return the reason for a refusal instead of False, and see how much the named constants help the message.
- Look for the same thresholds elsewhere in a codebase you know, and find out whether the two copies still agree.