| 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 — Freelancerino
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 this repo well:
-
Tenant isolation invariant: every query scopes by workspace_id.
- Existing reference:
scripts/test-client-isolation.ts
- Related skill:
.github/skills/drizzle-tenant-queries/SKILL.md
-
Server/client boundary isolation: no server-only imports in client code.
- Existing reference:
scripts/test-client-isolation.ts
-
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:
.github/skills/nextjs-cost-performance/SKILL.md
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 this repo, Server Actions are the right place for boundaries:
- Related skill:
.github/skills/nextjs-server-actions/SKILL.md
- Related security rules:
.github/skills/security-strict/SKILL.md
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
.github/skills/nextjs-cost-performance/SKILL.md).
- Don’t trade tenant safety for caching; workspace scoping is non-negotiable (see
.github/skills/security-strict/SKILL.md).
References (public)
Repository anchors
- Tenant-safe queries:
.github/skills/drizzle-tenant-queries/SKILL.md
- Server actions conventions:
.github/skills/nextjs-server-actions/SKILL.md
- Security rules:
.github/skills/security-strict/SKILL.md
- Performance deep dive:
.github/skills/nextjs-cost-performance/SKILL.md
- Code hygiene pass:
.github/skills/code-hygiene/SKILL.md