Back to Blog

Preventing Architectural Decay with Custom Lint Rules

Shashwat Dixit6 min read

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:

  1. Importing the base api object from auction.urls
  2. 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

  1. Architecture without enforcement is just a suggestion. Decompose your monolith all you want — without lint rules, it’ll re-accumulate.

  2. 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.

  3. Target your linter narrowly. Only run on urls.py files. Don’t scan the whole codebase on every lint run.

  4. 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.

  5. The best lint rules prevent problems that code review catches inconsistently. If reviewers sometimes miss it, automate it.

  6. 43 lines of prevention > 43 hours of cleanup. The cost-benefit ratio of simple automated checks is extraordinarily high.