Layout
platform/
├── CLAUDE.md # committed — project facts
├── CLAUDE.local.md # gitignored — your machine
├── .gitignore # contains CLAUDE.local.md, settings.local.json
├── .claude/
│ ├── settings.json # committed — env, permissions, plugins
│ ├── settings.local.json # gitignored — your overrides
│ ├── rules/
│ │ ├── java.md # paths: **/*.java
│ │ ├── go.md # paths: **/*.go
│ │ ├── python.md # paths: **/*.py
│ │ ├── typescript.md # paths: **/*.{ts,tsx}
│ │ ├── migrations.md # paths: **/migrations/**/*.sql
│ │ └── security.md # no paths: — always on
│ ├── skills/
│ │ ├── api-review/SKILL.md
│ │ ├── release-notes/SKILL.md
│ │ └── deploy/SKILL.md # disable-model-invocation: true
│ ├── agents/
│ │ ├── log-triage.md
│ │ └── test-analyst.md
│ ├── hooks/
│ │ ├── format.sh
│ │ └── guard-paths.sh
│ └── output-styles/
│ └── review-mode.md
├── services/
│ ├── billing/CLAUDE.md # lazy — Java service specifics
│ └── ingest/CLAUDE.md # lazy — Go service specifics
└── web/CLAUDE.md # lazy — frontend specifics
Root CLAUDE.md, kept deliberately short
# platform
Monorepo. Java services, Go tooling, Python jobs, TypeScript frontend.
## Commands
| Task | Command |
|---|---|
| Build everything | `make build` |
| Test one service | `make test SVC=billing` |
| Lint everything | `make lint` |
| Local stack | `make dev-env` |
## Layout
- `services/` deployable services, one directory each
- `tools/` Go CLIs, not deployed
- `jobs/` Python batch jobs
- `web/` TypeScript frontend
- `proto/` shared schemas. **Generated code is never edited by hand.**
## Rules of the road
- Language conventions live in `.claude/rules/`. They load automatically.
- Cross-service changes need an entry in `docs/decisions/`.
- Never edit anything under `**/generated/`.
@./docs/architecture.md
Note what is not here: no Java style guide, no Go error-handling convention,
no test-naming rules. Those are in .claude/rules/, where they cost nothing
during a TypeScript session.
.claude/settings.json, committed
{
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"env": {
"JAVA_HOME": "/usr/lib/jvm/java-21-openjdk",
"MAVEN_OPTS": "-Xmx2g",
"GOFLAGS": "-mod=mod",
"GOPRIVATE": "github.example.internal/*",
"PYTHONPATH": "./jobs/src",
"BASH_DEFAULT_TIMEOUT_MS": "300000"
},
"permissions": {
"allow": [
"Bash(make *)",
"Bash(go test *)",
"Bash(git status *)",
"Bash(git diff *)"
],
"deny": [
"Write(**/generated/**)",
"Edit(**/generated/**)",
"Read(**/secrets/**)",
"Bash(git push --force *)"
]
},
"hooks": {
"PreToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": ".claude/hooks/guard-paths.sh" }]
}
],
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [{ "type": "command", "command": ".claude/hooks/format.sh" }]
}
]
}
}
.claude/settings.local.json, gitignored
{
"env": {
"GITHUB_TOKEN": "ghp_...",
"JAVA_HOME": "/opt/homebrew/opt/openjdk@21"
},
"outputStyle": "Concise"
}
Your JAVA_HOME beats the committed one because local outranks project. Your
token never enters git.
A nested memory file
services/billing/CLAUDE.md loads only when Claude opens something in that
directory, so it can afford to be specific.
# billing
Spring Boot 3.3 on Java 21. Owns the subscription lifecycle; invoicing lives in
`services/invoicing` and is reached over the internal gRPC API only.
## Non-obvious
- `BillingClock` is injected everywhere. Nothing calls `Instant.now()`.
- Provider webhooks land in `WebhookController` and are idempotent by
`provider_event_id`. Never make one non-idempotent, and never add a side
effect outside the transaction.
- `SubscriptionState` is a state machine in `domain/`. New states need a
transition test; the enum alone is not enough.
- Integration tests use Testcontainers, so they need a running Docker daemon.
`make test SVC=billing` starts one.
The enforcement layer
Prose says “never edit generated code”. The permissions.deny above makes it
true, and the guard-paths.sh hook adds an explanation when it fires. Belt and
braces: the prose explains why, the permission guarantees what, and the hook
tells Claude what to do instead.
That triple is worth applying to every genuinely load-bearing constraint:
| Layer | Role | Example |
|---|---|---|
| Prose | Explains the reason, so Claude routes around the constraint intelligently | “Generated code comes from proto/; regenerate rather than edit” |
| Permission | Makes the violation impossible | Write(**/generated/**) in deny |
| Hook | Supplies the remedy at the moment of failure | “Edit the schema in proto/ and run make generate” |
Where each decision lands
| You want | Put it in |
|---|---|
| Personal preference, every project | ~/.claude/CLAUDE.md |
| Personal preference, one project, uncommitted | ./CLAUDE.local.md |
| Team-wide project facts | ./CLAUDE.md |
| Java style guide | .claude/rules/java.md with paths: |
| Go style guide | .claude/rules/go.md with paths: |
| Toolchain env vars | .claude/settings.json → env |
| Secrets | .claude/settings.local.json → env |
| A repeatable review procedure | .claude/skills/api-review/ |
| Something that must never happen | permissions.deny or a PreToolUse hook |
| Noisy investigation work | .claude/agents/log-triage.md |
| A quieter response style | outputStyle in settings.local.json |
| All of the above, shared across repos | A plugin |
What this buys you
| Session | Configuration loaded |
|---|---|
Editing web/src/App.tsx |
Root CLAUDE.md, web/CLAUDE.md, typescript.md, security.md |
Editing services/billing/**.java |
Root CLAUDE.md, billing/CLAUDE.md, java.md, security.md |
| Writing a migration | Root CLAUDE.md, migrations.md, security.md |
No session ever pays for the other three languages. That is the entire reason the rules directory exists.