| name | architecture-hardparts-srp |
| description | Use this skill when designing or refactoring boundaries, responsibilities, and change drivers using practical software architecture “hard parts” thinking + SRP. |
Architecture (Hard Parts) + SRP Skill
Use this skill when you’re designing or refactoring code to be easy to change, safe to operate, and hard to break.
This skill synthesizes “hard parts” architecture thinking (trade-offs, change drivers, continuous validation) with SRP (single responsibility / one actor) into concrete coding practices.
This is intentionally practical: if a principle doesn’t change how you structure code, dependencies, tests, or release safety, it doesn’t belong here.
When to use
- A file/class/module is becoming a god object (too many unrelated reasons to change).
- A feature change causes a ripple effect across unrelated folders.
- You’re unsure where a responsibility belongs (domain vs UI vs persistence vs integration).
- You’re introducing a new subsystem (billing, PDF, webhook, sync, reporting).
- You need to define/refine module boundaries and avoid future refactors.
The core framing: “change drivers” and “actors”
SRP isn’t “one function per file” — it’s:
- Gather together the things that change for the same reasons.
- Separate things that change for different reasons.
A practical test:
- If two different stakeholder groups (actors) would request changes to the same module, you likely violated SRP.
Examples of different “actors” in production code:
- Product/UX changes (copy, layout, flows)
- Compliance/accounting changes (invoice rules, taxes)
- Infra/SRE changes (logging, metrics, tracing)
- Vendor changes (Clerk/Stripe SDK updates)
- Data model changes (DB schema)
Non-negotiable outcomes (what good looks like)
A module with good SRP and good architecture should be:
- Cohesive: all contents work toward one purpose.
- Loosely coupled: callers depend on a stable interface, not internal details.
- Locally changeable: most changes touch 1–3 files in one folder.
- Testable in isolation: you can test core logic without Next.js runtime, DB, or network.
- Operable: logs/metrics/errors are structured and actionable.
Quick checklist (use during implementation)
Responsibility & boundaries
Coupling control
Reversal & evolution
Practical patterns (how to write SRP code that survives production)
1) Functional core, imperative shell
- Core: pure domain logic (deterministic, testable, no IO)
- Shell: IO orchestration (DB, fetch, SDKs, logging, Next.js)
Rule of thumb:
- If you can’t unit test it without mocking half the world, it probably doesn’t belong in the core.
2) Ports & adapters (a.k.a. dependency inversion)
Keep “hard to change” dependencies behind an adapter:
- Domain code depends on an interface/port.
- Infrastructure provides an adapter implementation.
Examples of volatility to isolate:
- External providers (Clerk, Stripe)
- Storage (Drizzle queries, schema details)
- Serialization formats (webhooks, PDFs)
3) Narrow, intention-revealing interfaces
Prefer function signatures that reflect intent:
- Good:
createInvoiceSnapshot(workspaceId, invoiceId)
- Bad:
saveInvoice(data: any)
Also:
- Pass the smallest inputs needed.
- Return “shaped” outputs (DTOs) instead of leaking DB row types.
4) One module = one reason to change
A file is allowed to be “big” if it changes for one reason.
A file is not allowed to contain:
- domain rules + formatting + persistence + network + UI
Split by change driver, not by arbitrary size.
5) Make trade-offs explicit with ADRs
Architecture is mostly about trade-offs. When the downside matters, record it.
A lightweight ADR in the repo (recommended location: docs/adr/):
- Title: short noun phrase
- Context: forces/constraints, what matters, what’s changing
- Decision: “We will …”
- Status: proposed/accepted/superseded
- Consequences: positives and negatives; what becomes easier/harder
- How we’ll verify: tests, checks, budgets, alarms
Keep it 1–2 pages. Write it as a message to future maintainers.
“Fitness functions” (guardrails that keep architecture from rotting)
A fitness function is an automated check that fails when architecture drifts.
Examples that fit multi-tenant projects well:
-
Tenant isolation invariant: every query scopes by workspace_id.
- Existing reference: your tenant isolation test (if you have one)
- Related skill: drizzle-tenant-queries
-
Server/client boundary isolation: no server-only imports in client code.
- Existing reference: your server/client boundary checks (if you have them)
-
Dependency rules (suggested):
- UI modules must not import DB schema.
- Domain modules must not import Next.js runtime APIs.
-
Performance budgets (suggested): route-level budgets + “no accidental broad revalidation”.
- Deep dive: nextjs-cost-performance skill
Concrete refactoring recipes (SRP in real code)
Recipe A: split “pipeline” functions
Smell: one function does validate → authorize → DB → business logic → formatting → revalidate.
Refactor into:
- Boundary: parse/validate inputs (Zod)
- Boundary: authorize/derive workspace (tenant safety)
- Domain: compute/apply business rules
- Infrastructure: persist or call external systems
- Presentation: format results/errors
In many Next.js projects, Server Actions are the right place for boundaries:
- Related skill: nextjs-server-actions
- Related security rules: security-strict
Recipe B: remove control flags
Smell: doThing({ mode: 'create' | 'update' | 'delete' }).
Refactor into:
createThing()
updateThing()
deleteThing()
Each becomes SRP-friendly and easier to test.
Recipe C: stop passing whole records everywhere
Smell: function accepts a big object but uses only 1–2 properties.
Refactor:
- Accept only the needed fields.
- Or introduce a stable DTO type.
This reduces stamp coupling and makes changes less contagious.
Recipe D: isolate vendor SDKs
Smell: domain modules import vendor SDKs directly.
Refactor:
- Create
lib/integrations/<vendor>/ adapters.
- Define a small interface that matches your needs (port).
Now vendor upgrades don’t leak through the app.
SRP violation “smells” (and what to do)
- God module (lots of unrelated exports; constant churn)
- Split by change driver.
- Keep a thin facade only if it improves discoverability.
- Mixed concerns in the same function
- Apply Recipe A (pipeline split).
- High ripple changes
- Introduce a boundary and a stable contract.
- Hide volatility behind an adapter.
- Temporal coupling (“these happen together, so they live together”)
- Separate orchestration from operations.
- Make each step independently testable.
- Utilities dumping ground
- Move helpers into the domain that owns the change driver.
- Keep shared utils truly generic (formatting, type helpers), not business rules.
Cost & performance notes (keep it short)
- Prefer architectures that reduce coordination and churn: cohesive modules + narrow interfaces.
- Avoid abstractions that centralize everything (shared “god services”). They become latency and change hotspots.
- Keep invalidation narrow and intentional (see nextjs-cost-performance skill).
- Don’t trade tenant safety for caching; workspace scoping is non-negotiable (see security-strict skill).
References (public)
Project pointers (adapt to your repo)
- Tenant-safe queries: drizzle-tenant-queries skill
- Server actions conventions: nextjs-server-actions skill
- Security rules: security-strict skill
- Performance deep dive: nextjs-cost-performance skill
- Code hygiene pass: code-hygiene skill