Preventing Architectural Decay with Custom Lint Rules
How a 43-line Python script saved our team from re-introducing the same structural problem every sprint.
The Backstory
We had just finished extracting modules from our Django monolith. Each app now had its own API namespace, its own URL registration, its own directory. Clean boundaries. Clear ownership.
Then, two weeks later, a new revision landed:
# In a newly created app's urls.py
from auction.urls import api # <-- importing the BASE api
api.register(NewResource()) # <-- registering directly into it
The engineer wasn’t being careless — they were following the pattern they saw in older files that hadn’t been migrated yet. The old pattern was the path of least resistance.
This is architectural decay. You invest weeks decomposing a monolith, and then the structure slowly re-accumulates because the wrong pattern is still the easiest one to copy.
The Insight
Code review catches this sometimes. But code review is:
- Inconsistent (depends on who reviews)
- Late (the code is already written when you catch it)
- Manual (doesn’t scale with team size)
What we needed was a machine that catches this at arc lint time — before the code even reaches review.
The Lint Rule
I wrote a custom Arcanist linter that detects two things:
- Importing the base
apiobject fromauction.urls - Calling
api.register()after such an import
Here’s the entire script:
"""
Checks if the base api is imported and if so, checks if the resources are
registered using the app-specific api register.
If not, it leaves a lint message to fix the issue and migrate to the new
API registration pattern.
"""
import re
import sys
file_to_lint = sys.argv[1]
IMPORT_PATTERN = re.compile(
r'from\s+auction\.urls\s+import\s+.*\bapi\b'
)
REGISTER_PATTERN = re.compile(r'\bapi\.register\s*\(')
with open(file_to_lint, 'r') as f:
content_lines = f.readlines()
has_base_api_import = False
for i in range(len(content_lines)):
current_line = content_lines[i]
if IMPORT_PATTERN.search(current_line):
has_base_api_import = True
print(
"%s: Do not import the base `api` from auction.urls. "
"Use `get_app_api_register` instead to register APIs "
"under app-specific namespace. "
"See T36592 for migration guide." % (i + 1)
)
if has_base_api_import and REGISTER_PATTERN.search(current_line):
print(
"%s: Do not register resources into the base v1 API. "
"Use `register = get_app_api_register(\"<app_name>\")` "
"and then `register(<Resource>())` instead. "
"See T36592 for migration guide." % (i + 1)
)
43 lines. Two regexes. That’s it.
How It Works in Practice
When an engineer runs arc lint (or submits a diff for review), the linter runs on every modified Python file. If it detects the violation:
$ arc lint
>>> Lint for urls.py:
Error (check_base_api_registration)
3: Do not import the base `api` from auction.urls. Use
`get_app_api_register` instead to register APIs under
app-specific namespace. See T36592 for migration guide.
Error (check_base_api_registration)
7: Do not register resources into the base v1 API. Use
`register = get_app_api_register("<app_name>")` and then
`register(<Resource>())` instead. See T36592 for migration guide.
The engineer immediately knows:
- What they did wrong
- What the correct pattern is
- Where to find the full migration guide (the linked task)
The Correct Pattern
The lint message points engineers toward this approach:
# Before (WRONG — pollutes the global namespace)
from auction.urls import api
api.register(MyResource())
# After (CORRECT — app-scoped registration)
from common.api_utils import get_app_api_register
register = get_app_api_register("my_app")
register(MyResource())
The get_app_api_register function returns a registration helper scoped to the app’s namespace. APIs registered this way appear under /api/v1/my_app/... instead of the flat global /api/v1/... namespace.
Design Decisions
Why a Script and Not an AST-Based Linter?
For this specific check, regex is sufficient. The patterns we’re detecting are syntactically simple:
from auction.urls import ... api ...api.register(
An AST-based approach (using ast.parse) would be more robust against edge cases like multi-line imports or aliased imports. But in our codebase, these patterns always appear in their simple form. The 5-minute implementation cost of regex was better than the 2-hour cost of a full AST visitor for zero practical benefit.
Why Print Line Numbers?
Arcanist’s script-and-regex linter expects output in the format LINE_NUMBER: message. This integrates with the diff view — the lint warning appears inline next to the offending line.
Why Check has_base_api_import Before REGISTER_PATTERN?
Not every call to .register() is a problem. A perfectly valid pattern:
from my_app.api import app_api
app_api.register(MyResource()) # This is fine
We only flag api.register() when api was specifically imported from auction.urls. The boolean flag creates a two-step detection: first identify the dangerous import, then flag subsequent registrations.
Registering the Linter with Arcanist
In .arclint:
{
"linters": {
"check-api-registration": {
"type": "script-and-regex",
"script-and-regex.script": "python scripts/lint/check_base_api_registration.py",
"script-and-regex.regex": "/^(?P<line>\\d+): (?P<message>.*)$/m",
"include": ["(\\burls\\.py$)"]
}
}
}
This only runs on files named urls.py — where API registrations happen. Fast, targeted, no unnecessary work.
Results
In the 6 months after deploying this linter:
- Zero instances of new direct base-API registration shipped to production
- 3 violations caught during development (engineers fixed them before review)
- Review bandwidth saved — reviewers no longer had to manually check for this pattern
The General Pattern: Lint as Architecture Enforcement
This approach generalizes to any architectural rule that can be expressed as a code pattern:
| Rule | Detection Pattern |
|---|---|
| Don’t import from base API | from auction.urls import.*api |
| Don’t use raw SQL in views | connection.cursor() in views.py |
| Don’t access settings directly in models | from django.conf import settings in models.py |
Don’t use print() in production code |
\bprint\( not in tests/ or scripts/ |
| Don’t import between feature modules | from feature_a import in feature_b/ |
The investment is tiny (30 minutes to write, 10 minutes to register). The return is permanent enforcement without ongoing human effort.
Takeaways
-
Architecture without enforcement is just a suggestion. Decompose your monolith all you want — without lint rules, it’ll re-accumulate.
-
The error message IS the documentation. Include the correct pattern and a link to the migration guide directly in the lint output. Don’t make people search for the answer.
-
Target your linter narrowly. Only run on
urls.pyfiles. Don’t scan the whole codebase on every lint run. -
Regex is fine for structural patterns. You don’t need a full AST parser for every lint rule. Match the tool to the problem’s complexity.
-
The best lint rules prevent problems that code review catches inconsistently. If reviewers sometimes miss it, automate it.
-
43 lines of prevention > 43 hours of cleanup. The cost-benefit ratio of simple automated checks is extraordinarily high.