After building a custom Android Code Review skill for Claude Code, I wanted to go further: enforce Clean Architecture boundaries in real time and automatically lint every Kotlin file Claude touches. I implemented this with two shell-script hooks — arch_guard.sh and kotlin_lint.sh — and they've already caught violations that would have slipped through.
What Are Claude Code Hooks?
Hooks are shell scripts that Claude Code executes automatically at defined points in its tool lifecycle. They receive tool call context as JSON on stdin and can block, observe, or react to what Claude is about to do — before or after it acts.
There are four lifecycle events:
The critical difference: PreToolUse with exit 2 is a hard block. Claude sees the stderr output and cannot proceed with the edit. PostToolUse runs after the fact — ideal for side effects like linting.
Hook 1: arch_guard.sh — Stop Architecture Violations Before They're Written
In Clean Architecture, the rule is strict: the presentation layer must never import from the data layer. ViewModels and Composables should only talk to domain interfaces — use cases and repository contracts — never directly to RetrofitService, RoomDao, or any data.* class.
Without enforcement, this boundary erodes. A developer (or AI) reaches for a convenient class, the import slips in, and now your ViewModel is coupled to a database implementation detail.
arch_guard.sh runs as a PreToolUse hook on every Edit and Write call:
#!/usr/bin/env bash
# PreToolUse — Arch Guard
# Blocks edits where presentation/ files import from data/ layer
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
# Only care about Edit/Write on .kt files inside presentation/
if [[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]]; then exit 0; fi
if [[ "$FILE" != *"/presentation/"* ]]; then exit 0; fi
if [[ "$FILE" != *.kt ]]; then exit 0; fi
# Get the new content being written
if [[ "$TOOL" == "Edit" ]]; then
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.new_string // empty')
else
CONTENT=$(echo "$INPUT" | jq -r '.tool_input.content // empty')
fi
# Check for illegal cross-layer imports
if echo "$CONTENT" | grep -qE "import com\.rajedev\.ainewsapp\.data\."; then
echo "ARCH VIOLATION: presentation layer must not import from data layer." >&2
echo "File: $FILE" >&2
echo "Use domain layer (repository interface / use case) instead." >&2
exit 2
fi
exit 0
What happens when it triggers
If Claude tries to write a ViewModel that imports com.rajedev.ainewsapp.data.remote.NewsApiService directly, the hook fires before the file is touched:
ARCH VIOLATION: presentation layer must not import from data layer.
File: .../presentation/news/NewsViewModel.kt
Use domain layer (repository interface / use case) instead.
Claude receives this as a blocked tool call, reads the error, and self-corrects — rewriting the code to go through the proper domain interface instead. The violation never lands in the file.
How the hook receives context
Every hook gets a JSON payload on stdin. For an Edit call it looks like:
{
"tool_name": "Edit",
"tool_input": {
"file_path": "app/src/.../presentation/news/NewsViewModel.kt",
"old_string": "...",
"new_string": "import com.rajedev.ainewsapp.data.remote.NewsApiService\n..."
}
}
The hook parses this with jq, checks only the fields it cares about, and either exits 0 (allow) or exits 2 (block).
Hook 2: kotlin_lint.sh — Auto-Lint Every Kotlin File Claude Edits
The second hook runs after every Edit or Write on a .kt file, executing ktlintCheck automatically:
#!/usr/bin/env bash
# PostToolUse — Kotlin Lint
# Runs ktlintCheck after any .kt file is written/edited
INPUT=$(cat)
TOOL=$(echo "$INPUT" | jq -r '.tool_name')
FILE=$(echo "$INPUT" | jq -r '.tool_input.file_path // empty')
if [[ "$TOOL" != "Edit" && "$TOOL" != "Write" ]]; then exit 0; fi
if [[ "$FILE" != *.kt ]]; then exit 0; fi
PROJECT_ROOT="/Users/lruser/Documents/development/AINewsApp"
echo "Running ktlint on: $FILE"
cd "$PROJECT_ROOT" || exit 0
OUTPUT=$(./gradlew ktlintCheck 2>&1)
EXIT_CODE=$?
if [[ $EXIT_CODE -ne 0 ]]; then
echo "ktlint found issues:"
echo "$OUTPUT" | grep -A2 "\.kt:" | head -40
else
echo "ktlint passed."
fi
exit 0
This is a PostToolUse hook — it exits 0 regardless of lint results, meaning it never blocks Claude. Instead, it surfaces lint violations immediately in the session output. If there are formatting issues, Claude sees them in context and can fix them before the session ends.
No more "fix lint in CI" PR comments. Style issues appear at the same moment the code is written.
Wiring It Together: settings.json
Both hooks are registered in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/arch_guard.sh"
}
]
}
],
"PostToolUse": [
{
"matcher": "Edit|Write",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/kotlin_lint.sh"
}
]
}
]
}
}
The matcher field is a regex matched against the tool name. Edit|Write means both hooks fire on any file edit or creation. The hooks run in the project directory, so relative paths like .claude/hooks/arch_guard.sh resolve correctly.
The Bigger Picture: Skills vs Hooks
These two primitives are complementary:
Skills define what Claude should do — reusable, invocable knowledge about your stack, conventions, and review checklists.
Hooks define what Claude is allowed to do — automated guardrails that run unconditionally, regardless of which skill or prompt triggered the action.
A skill can tell Claude "prefer domain interfaces over data layer classes." A hook enforces it. The difference is the difference between a guideline and a constraint.
For teams where architectural correctness is non-negotiable — regulated industries, large codebases, onboarding new contributors — hooks are the mechanism that makes AI assistance trustworthy at scale.
Try It Yourself
Create .claude/hooks/ in your project
Write your guard scripts — they're plain bash, reading JSON from stdin
Register them in .claude/settings.json under the appropriate lifecycle event
Use exit 2 in PreToolUse hooks to hard-block; use exit 0 in PostToolUse for observation
The full hooks and skills from this project are available in the AI News App repo. The arch guard alone is worth it — it turns "please follow Clean Architecture" from a PR comment into a compile-time-style invariant.

No comments:
Post a Comment