Service Boundaries skill for Claude Code
Split services on evidence, not fashion. Claude weighs microservice proposals against concrete forces such as team count, deploy cadence, scaling, fault isolation and compliance, then backs the call with data: change coupling mined from git history, table ownership and synchronous call chains. The result is a keep, enforce, extract or merge recommendation with a step-by-step strangler plan.
- name
service-boundaries - size156 lines · ≈1,850 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Decides between a modular monolith and microservices and places service boundaries using evidence, by checking team topology, deploy cadence, scaling and isolation needs, change coupling mined from git history, data ownership, and synchronous call chains. Covers when to split and when to merge services back, extraction with the strangler fig pattern, database separation, and coupling metrics. Use when someone proposes microservices, when services always deploy together or share a database, when a monolith has grown painful, or when deciding what to extract first. Try asking
- Should we split our Rails monolith into microservices? Here's the repo
- Our three services always deploy together. Are the boundaries wrong?
- Which part of this codebase should we extract into a service first?
Use it when
- Someone proposes moving to microservices
- Services deploy in lockstep or share a database
- Choosing the first module to extract from a monolith
- Deciding whether to merge chatty services back together
Skip it when
- You have decided on a monolith and need to enforce modules: use modular-monolith
- You need to find the business contexts first: use domain-driven-design
How to check the result
The recommendation cites co-change ratios, table ownership and call-chain data for each candidate, and every extraction step can be deployed and rolled back on its own.
The common mistake it prevents
Splitting services by entity or technical layer while keeping a shared database, which creates a distributed monolith that deploys in lockstep.
A boundary is right when most changes stay inside it, it owns its data outright, and it can be deployed and fail independently. Splitting along technical layers or nouns (“UserService”) instead of business capabilities produces a distributed monolith: all the costs of microservices with none of the independence.
Ground rules
- Default to a modular monolith for one team or a young product. Split a service out only when a concrete force (below) justifies the operational cost.
- Boundaries follow business capabilities and bounded contexts (see domain-driven-design), not layers (API service, DB service) or entities.
- One service owns each table. Shared databases between services are coupling by another name.
- Measure coupling before and after. Opinions about boundaries are cheap; co-change data is not.
- Merging two services back is a legitimate outcome.
Decision: monolith or services?
| Force | Favors separate service | Favors staying in the monolith |
|---|---|---|
| Teams | Several teams blocked on each other’s deploys | One or two teams |
| Deploy cadence | Parts need very different release rhythms | Everything ships together anyway |
| Scaling profile | One part needs 50x the compute or special hardware (GPU) | Uniform load |
| Fault isolation | A crash or leak in one part must not take down checkout | Shared fate acceptable |
| Technology | Genuinely different runtime needed (ML in Python, core in Go) | Same stack fits |
| Compliance | Isolated scope for PCI/HIPAA reduces audit surface | No regulatory split |
| Change coupling | Area rarely changes with the rest | Changes routinely span the area and others |
| Operational maturity | CI/CD, tracing, on-call, IaC per service exist | Not yet |
If fewer than two forces clearly apply, keep it as a module and enforce the boundary in code (see modular-monolith).
Workflow
- [ ] 1. Map capabilities and current modules/services
- [ ] 2. Measure change coupling from git history
- [ ] 3. Map data ownership and cross-boundary queries
- [ ] 4. Map runtime calls: sync chains, fan-out, shared failure
- [ ] 5. Score candidate boundaries against the forces table
- [ ] 6. Choose: keep, enforce as module, extract, or merge
- [ ] 7. Plan extraction with strangler fig and data migration
2. Change coupling
python3 scripts/cochange.py --since "12 months ago" --prefix src/ --depth 1
python3 scripts/cochange.py --repo ../platform --depth 2 --top 40 --min-commits 10
The script maps changed files to modules (path prefix + depth), ignores mass-change
commits and lockfiles, and prints commits per module plus pairs ranked by coupling
(together / min(commits)). Interpretation:
- Coupling ≥ 0.5 between two areas proposed as separate services: they are one service, or the boundary cuts through a concept.
- A module coupled to everything is a shared kernel or a god module; split its responsibilities before extracting anything.
- An area with high commit volume and low coupling is the best extraction candidate.
For multi-repo systems, look for PRs/tickets that require coordinated releases across repos; that is the same signal.
3. Data ownership
- List tables and which modules read/write each. Tables written by more than one module are the hardest part of any split.
- Find cross-boundary joins (
grep -rn "JOIN" src/plus ORM relations crossing module folders). Each one becomes an API call, a replicated read model, or a reason to keep the two together. - Reporting queries across everything belong in a separate read store fed by events or CDC, not in either service.
4. Runtime coupling
| Smell | Why it hurts | Fix |
|---|---|---|
| Sync call chain of 4+ services on a request path | Latency adds, availability multiplies down | Collapse services or go async |
| Service A cannot serve any request when B is down | No real independence | Cache, async replication of needed data, or merge |
| Chatty calls (dozens per request) | Boundary cuts a cohesive workflow | Move the boundary |
| Lockstep deploys | Shared contracts change constantly | Merge, or stabilize the contract with versioning |
| Shared library containing domain models | Hidden coupling on deploy | Duplicate small types; publish contracts, not models |
5–6. Evaluate candidates
For each candidate boundary, record: capability, owned tables, inbound/outbound calls, co-change score with neighbors, forces that apply, operational cost (new pipeline, on-call, observability, secrets). Choose:
- Keep as is: no force applies.
- Enforce as a module: cohesive but no deploy/scale/isolation force.
- Extract: two or more forces, low change coupling, data can be owned cleanly.
- Merge: services with high co-change, chatty calls, or shared tables.
7. Extraction (strangler fig)
- Enforce the module boundary inside the monolith first: public interface only, no cross-module table access. Ship it and let it settle.
- Give the module its own schema; replace cross-module joins with calls through the interface.
- Stand up the new service behind the same interface; route a small percentage of traffic (feature flag or proxy) and compare results (shadow reads where possible).
- Migrate data ownership: dual-write via outbox or CDC, backfill, verify counts and checksums, then switch the source of truth.
- Remove the old code path and the monolith’s access to the tables.
Each step is independently deployable and reversible. Do not combine schema split, service extraction, and a rewrite in one step.
Coupling metrics reference
- Afferent coupling (Ca): modules depending on this one. Efferent (Ce): modules this one depends on. Instability I = Ce / (Ca + Ce); stable (low I) modules should be abstract and change rarely.
- Change coupling: from the script above.
- Deploy coupling: fraction of releases where two services deploy together.
- Runtime coupling: number of synchronous calls per user request crossing the boundary.
Gotchas
- Splitting by entity (UserService, OrderService, ProductService) maximizes calls between services for every use case.
- “We’ll share the database for now” rarely ends; schedule the data split up front or do not split.
- Microservices multiply the need for distributed tracing, contract tests, and idempotent messaging; budget for them (see event-driven-architecture, contract-testing).
- Team boundaries and service boundaries that disagree produce constant cross-team coordination (Conway’s law). Align them or expect friction.
- Network calls replacing in-process calls add latency and new failure modes; design timeouts and retries for every new edge (see idempotency-retries).
Output
**Recommendation:** Extract Notifications; keep Billing as an enforced module; merge
Pricing and Catalog services back together.
**Evidence:**
- Notifications: 410 commits, coupling ≤ 0.08 with all modules; needs separate scaling
for campaign bursts (forces: scaling, isolation).
- Pricing <-> Catalog: coupling 0.71 over 12 months, 9 cross-service calls per page view.
- Billing: high coupling with Orders (0.46) via shared invoices table; no deploy or
scaling force.
**Plan:** 5-step strangler extraction for Notifications (see section 7), 6–8 weeks.
**Risks:** email provider webhooks must move with the service; dual-write window of 2 weeks. Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/cochange.py
#!/usr/bin/env python3
"""Measure change coupling between modules from git history.
Usage:
python3 scripts/cochange.py [--repo .] [--since "12 months ago"] [--depth 2]
[--prefix src/] [--max-files 40] [--top 25] [--min-commits 5]
Each changed file is mapped to a module: the first --depth path segments after an
optional --prefix (with --prefix src/ and --depth 1, src/billing/x.ts -> billing).
For every pair of modules changed in the same commit, it reports:
together commits touching both modules
coupling together / min(commits of A, commits of B) (1.0 = always change together)
High coupling between modules that are meant to be independent (or between candidate
services) is evidence that the boundary is in the wrong place. Commits touching more
than --max-files files (mass renames, formatting) are ignored.
Exit codes: 0 = report printed, 1 = git failed or no commits matched.
"""
import argparse
import itertools
import subprocess
import sys
from collections import Counter
SKIP_PARTS = {"test", "tests", "__tests__", "spec", "docs", "node_modules", "vendor", "dist", "build"}
SKIP_FILES = ("package-lock.json", "pnpm-lock.yaml", "yarn.lock", "poetry.lock", "uv.lock",
"Cargo.lock", "go.sum", "CHANGELOG.md")
def git_log(repo, since):
cmd = ["git", "-C", repo, "log", "--no-merges", f"--since={since}",
"--name-only", "--pretty=format:@@commit %H"]
try:
out = subprocess.run(cmd, check=True, capture_output=True, text=True).stdout
except (OSError, subprocess.CalledProcessError) as exc:
print(f"error: git log failed: {exc}", file=sys.stderr)
sys.exit(1)
commits, current = [], None
for line in out.splitlines():
if line.startswith("@@commit "):
if current is not None:
commits.append(current)
current = []
elif line.strip() and current is not None:
current.append(line.strip())
if current is not None:
commits.append(current)
return commits
def module_of(path, prefix, depth):
if path.endswith(SKIP_FILES):
return None
if prefix:
if not path.startswith(prefix):
return None
path = path[len(prefix):]
parts = path.split("/")
if len(parts) <= depth: # file directly at this level, not inside a module
return None
mod = parts[:depth]
if any(p in SKIP_PARTS for p in mod):
return None
return "/".join(mod)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--repo", default=".")
ap.add_argument("--since", default="12 months ago")
ap.add_argument("--depth", type=int, default=2)
ap.add_argument("--prefix", default="")
ap.add_argument("--max-files", type=int, default=40)
ap.add_argument("--top", type=int, default=25)
ap.add_argument("--min-commits", type=int, default=5,
help="ignore modules changed in fewer commits (default 5)")
args = ap.parse_args()
commits = git_log(args.repo, args.since)
per_module, pairs = Counter(), Counter()
used = 0
for files in commits:
if not files or len(files) > args.max_files:
continue
mods = {m for m in (module_of(f, args.prefix, args.depth) for f in files) if m}
if not mods:
continue
used += 1
per_module.update(mods)
for a, b in itertools.combinations(sorted(mods), 2):
pairs[(a, b)] += 1
if not used:
print("error: no commits matched; check --prefix/--depth/--since", file=sys.stderr)
sys.exit(1)
active = {m for m, c in per_module.items() if c >= args.min_commits}
print(f"Analyzed {used} commits since '{args.since}', {len(active)} active modules "
f"(depth {args.depth}{', prefix ' + args.prefix if args.prefix else ''}).\n")
print("Commits per module:")
for m, c in per_module.most_common():
if m in active:
print(f" {c:5} {m}")
rows = []
for (a, b), n in pairs.items():
if a in active and b in active:
rows.append((n / min(per_module[a], per_module[b]), n, a, b))
rows.sort(reverse=True)
print(f"\nTop {args.top} coupled pairs (coupling = together / min(commits)):")
print(f" {'coupling':>8} {'together':>8} pair")
for ratio, n, a, b in rows[: args.top]:
flag = " <-- strong" if ratio >= 0.5 and n >= args.min_commits else ""
print(f" {ratio:8.2f} {n:8} {a} <-> {b}{flag}")
if not rows:
print(" no pairs changed together")
if __name__ == "__main__":
main()
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.