Replace Magic Numbers with Named Domain Values

A condition such as attempts >= 5 is easy to execute and surprisingly hard to review. Why five? Is it a security policy, a technical limit, a temporary experiment, or just an arbitrary value copied from somewhere else? The code contains the number but not the reason it exists.

A magic number is a numeric literal whose meaning isn’t clear from its context. Replacing one well means more than moving it into a constant. The goal is to preserve the value’s meaning, units, and ownership so a future change has an obvious place to happen.

The problem is missing meaning, not numeric literals

Numbers are normal parts of programs. Some are already clear:

remaining = total - 1
percentage = completed * 100 / total

Naming 1 as ONE_ITEM or 100 as ONE_HUNDRED would add indirection without explaining anything useful.

Compare that with:

if failed_attempts >= 5:
    lock_account()

The literal 5 represents a policy: the number of failed attempts allowed before an account is locked. That meaning matters when someone reviews the rule or changes it later.

A useful test is to ask: would a developer understand why this exact value is correct without searching elsewhere? If not, the value probably needs a name or a stronger representation.

Start with the smallest useful refactoring

For a value with one clear meaning, a named constant is often enough:

MAX_FAILED_LOGIN_ATTEMPTS = 5

if failed_attempts >= MAX_FAILED_LOGIN_ATTEMPTS:
    lock_account()

The program behaves exactly as before, but the condition now communicates a policy rather than an unexplained threshold.

The name also gives the value an identity. A code search for MAX_FAILED_LOGIN_ATTEMPTS finds uses of this policy. Searching for 5 would find unrelated page sizes, retry counts, array indexes, and test values.

This is why the refactoring is about meaning. FIVE = 5 would preserve the same ambiguity. The useful name answers what the number represents.

Include units when the type cannot express them

Time, sizes, rates, and distances are common sources of subtle magic numbers because the raw type doesn’t tell you the unit.

Consider:

if elapsed > 300:
    cancel_request()

Even if 300 has a name, this is still weak:

TIMEOUT = 300

Is that 300 milliseconds, seconds, or ticks? A better name carries the unit when the surrounding type cannot:

REQUEST_TIMEOUT_SECONDS = 300

If the language or library provides a duration type, prefer using it where practical:

REQUEST_TIMEOUT = duration.minutes(5)

The exact syntax depends on the platform, but the engineering principle is general: make incompatible units difficult to confuse.

The same reasoning applies to MAX_UPLOAD_BYTES, DISTANCE_METERS, or INTEREST_RATE_PERCENT. A name should remove the question a reviewer would otherwise have to ask.

Put the value where its rule is owned

Once a number has a name, the next question is where that name belongs.

Suppose several modules use a free-shipping threshold:

FREE_SHIPPING_MINIMUM = 50

Placing that constant in a generic constants file makes it easy to import, but it hides ownership. Is the threshold a checkout rule, a shipping rule, or a promotion rule?

If shipping policy owns the decision, keep the value with that policy:

class ShippingPolicy:
    FREE_SHIPPING_MINIMUM = Money(50, "USD")

    qualifies_for_free_shipping(order_total):
        return order_total >= FREE_SHIPPING_MINIMUM

Now callers can ask the policy a meaningful question rather than importing its threshold and reproducing the comparison themselves.

This is an important step beyond constant extraction. If callers need the number only to make a decision that belongs to another module, exposing the number may still leak the rule.

Prefer this:

shipping_policy.qualifies_for_free_shipping(order_total)

rather than this:

order_total >= ShippingPolicy.FREE_SHIPPING_MINIMUM

The first form lets the policy later become more complicated without teaching every caller about the new rule.

Don’t merge equal numbers that mean different things

Two literals can have the same numeric value and still represent unrelated decisions.

Imagine this code:

MAX_RETRY_ATTEMPTS = 3
MAX_VISIBLE_SUGGESTIONS = 3

Combining them into DEFAULT_THREE = 3 would create false coupling. If product design later wants five suggestions, the retry policy should not change with it.

Constants should be shared because they represent the same concept, not because their current values happen to match.

The reverse also matters. If the same business rule appears as 5 in several places, creating separate constants such as LOGIN_LIMIT = 5 and MAX_FAILURES = 5 may preserve duplication under different names. If both uses really implement the same policy, give that policy one owner.

Distinguish constants from configuration

A named constant is appropriate when the value is part of the program’s design and changes through a code change and deployment.

Some values need to vary by environment, tenant, experiment, or operational condition. Those are usually configuration or data rather than constants.

For example, this may be a genuine program constant:

SHA256_DIGEST_BYTES = 32

Its value follows from the representation the program has chosen.

A request timeout may be different:

REQUEST_TIMEOUT_SECONDS = 30

If operators need to tune that timeout without changing code, extracting the literal into a constant hasn’t solved the real problem. The value belongs in an appropriate configuration source, with validation and a documented default.

Don’t make every value configurable pre-emptively. Configuration adds its own failure modes: missing values, invalid values, inconsistent environments, and more runtime states to reason about. Use it when variability is a real requirement.

Tests need meaning too

Magic numbers often survive in tests even after production code becomes clearer.

Suppose the rule is:

MAX_FAILED_LOGIN_ATTEMPTS = 5

A test like this hides its boundary:

attempt_login_with_bad_password(5)
assert account.is_locked

A clearer test can describe why the input matters:

attempt_login_with_bad_password(MAX_FAILED_LOGIN_ATTEMPTS)
assert account.is_locked

But sharing production constants with tests is not automatically desirable. A test intended to verify a public requirement can become circular if it imports the exact implementation value it is supposed to check.

For example, if the requirement explicitly says accounts lock after five failures, a test containing 5 may be appropriate because the test independently states that requirement. In that case, the test name and setup should make the meaning obvious:

test_account_locks_after_five_failed_attempts

The choice depends on what the test is proving. Tests for implementation mechanics can reuse a constant. Tests that pin an externally defined rule may intentionally state the expected value independently.

Avoid turning every literal into a constant

Mechanical replacement creates code that is harder to read:

ZERO = 0
ONE = 1
PERCENT_SCALE = 100

Used indiscriminately, such names force readers to jump between symbols and definitions without gaining domain knowledge.

Indexes, simple arithmetic identities, and values whose meaning is obvious in a tiny local context often belong inline. Even a domain value can remain local when it is used once and the surrounding expression explains it clearly.

Compare:

progress_percent = completed / total * 100

with:

if order_total >= 50:
    shipping_cost = 0

The first 100 expresses a familiar percentage conversion. The second 50 is a business threshold whose origin and ownership are unclear. Treating them identically would miss the reason for the refactoring.

Watch for constants that should become richer types

A named number improves readability, but sometimes the underlying primitive is still too weak.

Money is a common example:

FREE_SHIPPING_MINIMUM_CENTS = 5000

This is much clearer than 5000, yet code can still accidentally compare the cents value with an amount expressed in dollars or with another currency.

A money type can carry both amount and currency. A duration type can carry its unit. A percentage or range type can validate acceptable values. These types are useful when the domain needs guarantees that a name alone cannot provide.

Don’t introduce a custom type merely because a constant exists. Use a richer type when operations, validation, or unit safety justify it.

Refactor by tracing the rule, not by replacing text

When you find a suspicious literal, first identify what it means. Then search narrowly for uses of the same rule, not just occurrences of the same number.

If three 30 literals mean a session timeout, a page size, and a retention period, they need three independent treatments. If one timeout appears as 30, 30_000, and 0.5 * 60 in different unit conventions, a text search for 30 won’t reveal the full duplication.

After identifying the rule, choose the smallest representation that makes it clear: an inline expression with obvious meaning, a named constant, a configuration value, a richer type, or a method that owns the whole decision.

Then verify the boundary cases around the value. For failed_attempts >= 5, the meaningful checks are usually just below the threshold and at the threshold. This catches accidental changes such as replacing >= with > while moving the value.

Give unexplained values an owner

When a number makes a reader ask “why this value?”, don’t stop at giving it a label. Identify the rule it represents, include units where needed, and put the rule with the code that owns the decision.

A named constant is often the right first move. Sometimes the better endpoint is configuration, a domain type, or a method that prevents callers from depending on the threshold at all.

The practical goal is simple: when the value changes, a developer should know what it means, why it exists, and where that change belongs.