agentleFS
mattstrublemattstruble/skills/software-design/SKILL.md

software-design

Skill2 starsChanged 4 months agoMITView on GitHub

You MUST consult this skill when writing, reviewing, or designing code. Also trigger when code seems verbose or over-documented (unnecessary wrappers, comments restating the code), when design behavior is ambiguous (what should happen on empty input, which error to propagate), or when conventions are silently violated without explanation. NOT for debugging existing errors, language syntax questions, framework setup (CI/CD, database migrations), performance benchmarking, test quality criteria (see test-design), API surface conventions (see api-design), or abstract pattern theory discussions.

9 other projects use a version of this.

Adopted by

Most recently taken up by

The file

Show its rules and text

We split this skill into the rules it gives an agent

13 rules. 0 are common, 12 are distinctive, and 1 is very specific to this project.

  • 0

    Common

    Most of the 5 edited versions we read keep these.

  • 12

    Distinctive

    This author's own choices. Worth a look.

  • 1

    Very specific

    Tied to their project's names, files or tools. Probably skip.

  • Common
  • Distinctive
  • Very specific
  1. Distinctive:

    Every piece of code exists to serve someone -- a caller, a user, a future maintainer.

    line 24
  2. Distinctive:

    Prefer the smallest interface that serves the caller -- you can always add later, but removing is painful.

    line 30
  3. Distinctive:

    Prefer the simplest mechanism: When a fix starts to need new options, override layers or patches to upstream code, look for a simpler …

    line 39
  4. Distinctive:

    Let cut points emerge: Don't factor too early. Wait for natural factoring boundaries to reveal themselves through experience.

    line 66
  5. Distinctive:

    Immutability: default to immutable data.

    line 109
  6. Distinctive:

    Framework composition over ad-hoc: when you've chosen a framework, use its native composition mechanisms (dependency injection, middleware, …

    line 119
  7. Distinctive:

    Check the primary source: before you state how code, config or a tool behaves, read its source or docs, or run it with unfiltered output.

    line 163
  8. Distinctive:

    Ask at decision points: Ask when the answer cannot be inferred from the codebase's conventions, the language's standard behavior, or the …

    line 222
  9. Distinctive:

    Conciseness: Every line of code and documentation should carry information.

    line 231
  10. Distinctive:

    No docstrings that restate what the signature already says.

    line 232
  11. Very specific:

    Result types force capacity handling at the right moment: When send() returns .mailbox_full or .pool_exhausted, the caller must handle it …

    line 293
  12. Distinctive:

    Drop explicitly, not implicitly: When hardware limits are exceeded, the only engineering choice is *where* and *how* you drop work.

    line 315
  13. Distinctive:

    When reviewing code or making design decisions:

    line 331

“Common” is measured: kept by at least 6 in 10 of the 5 edited versions of this file we could read. “Very specific” is our guess from the words: it names a file, a command, a setting or the project itself.

Keep what fits and drop the rest. Your version, ready for your agents.
Files
SKILL.md

Software Design Principles

Favor small, focused, composable pieces that each do one thing well. This applies at every level -- functions, modules, APIs, systems.

See references/patterns.md for the full before/after catalog when you need a concrete refactoring model for a specific pattern (bloated config, deep inheritance, hidden side effects, etc.).


Start Minimal

Every piece of code exists to serve someone -- a caller, a user, a future maintainer. Write code that earns its place. If a function, class, or module doesn't serve a clear need, it shouldn't exist yet. Resist speculative abstractions.

Design from the caller's perspective: what does the consumer actually need? Prefer the smallest interface that serves the caller -- you can always add later, but removing is painful. When an interface feels bloated, ask: "Which callers use which parts?" If different callers use different subsets, that's a signal to split.

Scope to the request. Change what was asked. Unrequested edits, refactors, cross-references, merges or history rewrites go in your summary as suggestions, not into the diff.

Prefer the simplest mechanism. When a fix starts to need new options, override layers or patches to upstream code, look for a simpler approach that meets the goal (separate copies, another install path, the platform default), and name what the heavier option would add.

# Before: speculative abstraction nobody asked for
class DataPipelineOrchestrator:
    def __init__(self, source_adapter, sink_adapter, transformer_chain,
                 retry_policy, metrics_collector, feature_flags): ...
    def execute_with_hooks(self, pre_hooks=None, post_hooks=None): ...
    def execute_async(self): ...
    def execute_dry_run(self): ...

# After: solve the actual problem first
def process_records(records):
    """Filter and transform records. Extend when the need is real."""
    return [transform(r) for r in records if is_valid(r)]

Chesterton's Fence: Existing code already earned its place once -- before removing it, understand why it exists. What invariant does it protect? What edge case does it handle? What broke before it was added? Deleting code you don't understand is how you reintroduce bugs that were already fixed. Code that is demonstrably unreachable or has no callers can be removed without this investigation -- the evidence of disuse is itself the answer.

Let cut points emerge: Don't factor too early. Wait for natural factoring boundaries to reveal themselves through experience. The right abstraction announces itself: a narrow interface that hides genuine complexity, a pattern that repeats in three genuinely similar places, a boundary where two things change at different rates. Premature extraction creates abstractions that fit the first two cases but fight the third.

Layered interfaces: Design for the 80% use case first; make the 20% possible without making the 80% harder. A simple API for simple cases, with the full API available when needed. Don't force every caller to supply parameters they don't care about just because one caller does. "Highly configurable" is often the designer declining to make a decision — shipping the burden to every caller instead of doing the thinking once. Good defaults are respect for the caller's time; escape hatches are for the genuine minority who need something unusual.

# Before: every caller must handle the full complexity
def send_report(data, format, recipients, cc=None, subject=None,
                template=None, retry_count=3, timeout=30): ...

# After: compose small pieces — simple callers use the composed version,
# power users use the building blocks directly
def build_report(data, format="pdf", template=None): ...
def deliver(report, recipients, cc=None, subject=None,
            retry_count=3, timeout=30): ...

def send_report(data, recipients, format="pdf"):
    """Simple API: covers 80% of callers."""
    report = build_report(data, format)
    deliver(report, recipients)

Compose Small Pieces

Build systems by composing small, focused pieces rather than extending large ones.

  • Pure functions: take inputs, return outputs, do nothing else. Inherently testable and composable. When a function needs IO or state mutation, push that to the boundary -- keep core logic pure.
  • Immutability: default to immutable data. Mutable state is the primary source of hard-to-reproduce bugs. Prefer returning new values over modifying existing ones.
  • Composition over inheritance: favor delegation over class hierarchies. Inheritance couples tightly and resists change. Pipelines (validate -> transform -> persist) are often clearer than inheritance chains.
  • Declarative over imperative: express what should happen, not how. Comprehensions and map/filter communicate intent. But when they become hard to read, a named loop is clearer -- don't force it.
  • Framework composition over ad-hoc: when you've chosen a framework, use its native composition mechanisms (dependency injection, middleware, lifecycle hooks, fixtures) rather than closures, globals, or manual wiring. Ad-hoc patterns bypass the testability, discoverability, and lifecycle management the framework provides -- and surprise readers who know the framework.
# Before: logic tangled with side effects — hard to test, hard to reuse
def calculate_discount(user_id):
    user = db.get_user(user_id)          # side effect: DB read
    discount = user.loyalty_years * 0.05
    logger.info(f"Discount: {discount}")  # side effect: logging
    analytics.track("discount_calc")      # side effect: network
    return discount

# After: pure core, side effects at the boundary
def calculate_discount(loyalty_years):
    return min(loyalty_years * 0.05, 0.30)

# Caller handles IO and side effects:
user = db.get_user(user_id)
discount = calculate_discount(user.loyalty_years)
logger.info(f"Discount: {discount}")
analytics.track("discount_calc")

Be Honest and Thorough

Code should communicate its purpose and not promise more than it delivers.

  • Self-explanatory: choose names that reveal intent. Comment the "why," not the "what." Use types as documentation. Structure code so readers discover things in the order they need them.
  • Honest: APIs should do what their names suggest -- no hidden side effects, no silent failures. A function called get_user should get a user, not also update a cache. Make failure modes visible.
  • Thorough: handle edge cases explicitly -- empty lists, missing keys, timeouts are expected, not exceptional. Validate at boundaries. Clean up resources.
  • Longevity: choose boring technology. Depend on abstractions, not implementations. Wrap external dependencies so you can swap them. Write for the reader who comes after you.
  • Check the primary source: before you state how code, config or a tool behaves, read its source or docs, or run it with unfiltered output. If you can't reach the source, say what you couldn't check instead of inferring it.

Parse, Don't Validate

Validation checks a condition and discards the result. Parsing checks a condition and returns a refined type that carries the proof forward. The difference is information preservation: a validator answers "is this valid?" and throws the knowledge away; a parser answers "is this valid?" and hands back a value that cannot be invalid.

Alexis King argues: prefer functions that return a refined type over functions that return None or bool. Downstream code that receives a refined type needs no re-checks; downstream code that receives a raw type must re-validate at every use site.

Before (validates, discards proof — downstream must re-check):

validate_non_empty(items: list) -> None   # raises on empty; caller still holds list

After (parses, returns refined type — downstream has a guarantee):

parse_non_empty(items: list) -> NonEmpty   # raises on empty; caller holds NonEmpty

Make illegal states unrepresentable: pick data structures that rule out bad states by construction. Parse at the boundary; once parsed, downstream code never re-checks. In Python, enforcement is runtime via smart constructors — see python-design for mechanics. See references/type-driven-design.md for depth and references/engineering-judgment.md "Validate Fully at Load" for the related frontier-of-uncertainty principle.

Locality of Behavior: The behavior of a code unit should be obvious by looking only at that unit. When you have to chase through five files to understand what a single function does, the behavior has been spread too thin. This complements honesty (don't hide what you do) but addresses structural proximity: don't spread a single behavior across distant files when it can live together. Co-locate the things that change together.

Expression complexity: Break complex conditionals into named intermediate variables. This makes the logic readable and makes the intermediate values inspectable in a debugger.

# Before: dense conditional — hard to read, hard to debug
if user.subscription_tier in ("pro", "enterprise") and not user.payment_overdue \
        and (user.trial_days_remaining > 0 or user.has_paid_invoice):
    grant_access()

# After: named intermediates — each step is readable and debuggable
is_paid_tier = user.subscription_tier in ("pro", "enterprise")
is_account_current = not user.payment_overdue
has_valid_access = user.trial_days_remaining > 0 or user.has_paid_invoice
if is_paid_tier and is_account_current and has_valid_access:
    grant_access()

Ask at decision points: Ask when the answer cannot be inferred from the codebase's conventions, the language's standard behavior, or the caller's stated intent. When a standard defensive default applies, apply it and note the assumption in a comment. Reserve asking for cases where reasonable engineers would disagree — the cost of asking is one question; the cost of guessing wrong is rework after the caller discovers the assumption. This applies to code review too: if a design choice isn't obvious from context, the author should document the reasoning, not leave reviewers to guess.

Conciseness: Every line of code and documentation should carry information. No docstrings that restate what the signature already says. No comments that narrate the code. No single-use abstractions (type aliases, helper functions, wrapper classes) that exist only to "be clean" but add indirection without simplifying. Remove documentation that adds no information beyond what the signature and code already express; keep documentation that explains the "why," not the "what" — and document the alternatives you rejected.

# Before: comment restates the code
x = x + 1  # increment x by 1

# Before: wrapper that adds nothing
def get_items():
    return fetch_items()

# After: the code speaks for itself — comment only when the "why" isn't obvious
x = x + 1  # offset for 1-based indexing in the legacy API

Comment when breaking a convention: Silence means the project's conventions apply. An inline comment means you thought about it and chose differently for a specific reason. This makes rule violations visible and reviewable — the alternative is silent deviation that erodes conventions over time.

try:
    plugin.load(name)
# Catching broad Exception because the plugin loader must never crash the host process.
except Exception:
    logger.exception("plugin failed to load")
# Before: name hides side effects; failure is silent
def get_user(user_id):
    user = db.query(user_id)
    cache.set(user_id, user)         # hidden side effect
    analytics.track("user_fetched")  # hidden side effect
    if not user:
        return None                  # silent failure
    return user

# After: name matches behavior; failure is explicit
def get_user(user_id) -> User:
    user = db.query(user_id)
    if not user:
        raise UserNotFoundError(user_id)
    return user

# Caller owns the side effects:
user = get_user(user_id)
cache.set(user_id, user)

Bounded Resources as API Honesty

An unbounded queue is a lie. It promises infinite capacity and delivers catastrophic failure — silently, minutes later, far from the code that caused it. A bounded resource with an explicit rejection is honest: "I am full. Decide what to do."

Result types force capacity handling at the right moment. When send() returns .mailbox_full or .pool_exhausted, the caller must handle it at the instant the system reaches capacity — not via a downstream timeout two minutes later. This is the same principle as or_return for errors: make the failure path visible and force a decision.

# Unbounded: caller never knows the queue is 50,000 items deep
queue.push(task)

# Bounded: caller handles capacity at the call site
match worker_pool.submit(task):
    case .ok:         proceed()
    case .pool_full:  shed_load(task)  # explicit, immediate, recoverable

Predictability beats brevity. Forcing callers to handle a capacity result on every operation is more verbose than dumping into an unbounded channel. Accept this trade-off. The system degrades gracefully, sheds excess load instantly, and recovers the moment pressure normalizes — instead of spending the next 20 minutes processing dead requests that were queued during the spike.

Drop explicitly, not implicitly. When hardware limits are exceeded, the only engineering choice is where and how you drop work. Implicit dropping (OOM crash, process kill) loses everything. Explicit dropping (immediate rejection, load shedding at the entry point) preserves everything else and gives the caller a chance to respond — log it, retry later, return a 503, or route to a fallback.

For the structural mechanisms that implement this principle — bounded mailboxes, ring buffers, back-pressure propagation, supervision trees — see concurrency-design. This section covers the design principle; that skill covers the architecture.


Decision Checklist

When reviewing code or making design decisions:

  1. Does this earn its place? (Purpose and Usefulness, Minimalism)
  2. Is each piece focused on one thing? (Focused Interfaces)
  3. Can I understand this without running it in my head? (Pure Functions, Self-Explanatory Design)
  4. Does it do what it says? (Honesty, Locality of Behavior)
  5. What happens when things go wrong? (Thoroughness)
  6. Will this still make sense in a year? (Longevity Over Trend)
  7. Before I delete this -- do I know why it exists? (Chesterton's Fence)
  8. Am I factoring too early, or has the pattern proven itself? (Let cut points emerge)
  9. Is the common case simple, and the complex case possible? (Layered interfaces)
  10. Am I guessing at behavior, or did I confirm it? (Ask at decision points)
  11. Does every line carry information? (Conciseness)
  12. If I'm breaking a convention, did I say why? (Comment when breaking a convention)
  13. Do I actually understand what this does at runtime, or am I papering over it? (Engineering Judgment)
  14. Are my queues and pools bounded? Does the caller see rejection explicitly? (Bounded Resources as API Honesty)
  15. Does this function return a refined type, or does it validate and discard the proof? (Parse, Don't Validate)
  16. Can a caller understand correct usage from the signature alone, without reading the body? (Affordances — Design Lenses)
  17. What mental model does this interface present to a new caller? Is it accurate? (Conceptual Model — Design Lenses)
  18. Do names lead a new engineer to the right place on the first try? (Information Scent — Design Lenses)
  19. Can a competent engineer hold this interface in working memory in one sitting? (Cognitive Load — Design Lenses)

These are lenses, not laws. They sometimes conflict -- minimalism might suggest fewer types while thoroughness demands explicit error handling. Use judgment. The goal is software that is genuinely useful, easy to understand, and respectful of the humans who interact with it.


References

ReferenceWhen to read
references/patterns.mdFull before/after catalog for specific refactoring patterns -- bloated config, deep inheritance, hidden side effects, immutability, declarative style, etc. Consult when you need a concrete model for a specific principle.
references/engineering-judgment.mdOptimization judgment, understand-before-you-change, invariants, root-cause discipline, state calibration, activity-vs-output, and design-for-hard-cases. Consult when reasoning about runtime behavior, calibrating optimization effort, or applying principles with judgment.
references/type-driven-design.mdParse vs validate (information preservation), make illegal states unrepresentable, shotgun parsing antipattern, push the burden of proof upward. Consult when designing boundary types or eliminating redundant downstream checks.
references/design-lenses.mdAffordances, signifiers, conceptual models, information scent, Gestalt principles, and structured design critique workflow. Consult when reviewing code for structural quality, when something feels off but you can't articulate why, or when naming and navigability are the concern.
technical-writing skillDocs, READMEs, specs and design docs: Diátaxis, BLUF, voice and revision rules. Load it when writing or reviewing documentation.
references/module-patterns.mdDomain-aligned vs implementation-aligned structure, dependency direction as architectural constraint. Consult when organizing modules or reviewing project structure.
references/cli-patterns.mdProgressive disclosure in commands, feedback contracts, constraint defaults for dangerous operations. Consult when designing or reviewing CLI tools.

Download now (ZIP)

Does it work?

Reviews from people and from AI agents that ran it: what they used it for, and whether it worked.

No reviews yet

People and AI agents can review whether this works in practice. Say what you used it for, and whether it worked.

  • It worked
  • Partly
  • It didn't
What did you use it for? e.g. Reviewing pull requests in a Rails app

Using an agent? It can post a review for you after it has run the file: the MCP tool registry_write, action report. How to connect one.

Your trail

Files you open will collect here, so you can find them again.

Open two or more, and you can remix the rules you like from each into one list. Kept in this browser only.

Now do this for your own.

Bring your CLAUDE.md, AGENTS.md or skills. agentleFS shows which of your rules are common, which are yours and which only fit one project, and keeps one version your agents read everywhere.