From fbedc50e7e1a4ecd7e644247c3c41f6f717aecfd Mon Sep 17 00:00:00 2001 From: Hong Minhee Date: Wed, 8 Jul 2026 14:51:46 +0900 Subject: [PATCH] Add graphql-schema-design agent skill Install the graphql-schema-design skill under .agents/skills/ and symlink it to .claude/skills. Exclude these skill paths from Markdown formatting checks in .hongdown.toml and track the skill in skills-lock.json. --- .agents/skills/graphql-schema-design/SKILL.md | 217 +++++++ .../references/connections.md | 435 ++++++++++++++ .../references/errors.md | 352 +++++++++++ .../references/evolution.md | 429 +++++++++++++ .../references/mutations.md | 563 ++++++++++++++++++ .../references/naming.md | 380 ++++++++++++ .../references/nullability.md | 289 +++++++++ .../graphql-schema-design/references/types.md | 515 ++++++++++++++++ .claude/skills | 1 + .hongdown.toml | 8 +- skills-lock.json | 11 + 11 files changed, 3199 insertions(+), 1 deletion(-) create mode 100644 .agents/skills/graphql-schema-design/SKILL.md create mode 100644 .agents/skills/graphql-schema-design/references/connections.md create mode 100644 .agents/skills/graphql-schema-design/references/errors.md create mode 100644 .agents/skills/graphql-schema-design/references/evolution.md create mode 100644 .agents/skills/graphql-schema-design/references/mutations.md create mode 100644 .agents/skills/graphql-schema-design/references/naming.md create mode 100644 .agents/skills/graphql-schema-design/references/nullability.md create mode 100644 .agents/skills/graphql-schema-design/references/types.md create mode 120000 .claude/skills create mode 100644 skills-lock.json diff --git a/.agents/skills/graphql-schema-design/SKILL.md b/.agents/skills/graphql-schema-design/SKILL.md new file mode 100644 index 0000000..6e3427c --- /dev/null +++ b/.agents/skills/graphql-schema-design/SKILL.md @@ -0,0 +1,217 @@ +--- +name: graphql-schema-design +description: GraphQL schema design and review. Use when designing new GraphQL schema changes (types, mutations, queries, connections, enums, errors), reviewing schema diffs, planning schema evolution, or auditing nullability and naming. Triggers on 'graphql schema design', 'design a mutation', 'design a query', 'design a type', 'new type', 'new mutation', 'review schema', 'review schema diff', 'schema review', 'schema evolution', 'audit the schema', '/graphql-schema-design'. +metadata: + argument-hint: "[feature description | 'review']" +--- + +# GraphQL Schema Design + +You are a senior API architect. Your job is to ensure every GraphQL schema change is intentional, client-friendly, and evolvable. You do NOT write implementation code — you produce SDL proposals and design feedback. Implementation belongs to the sibling skill `graphql-backend`. + +The rules in this skill are framework-agnostic GraphQL design conventions. Examples use a Book/Author domain throughout, but the patterns apply to any GraphQL API. + +## Mode Detection + +Parse the user's request to determine the mode: + +- **Design mode** (default): The user is describing a feature, use case, or need. + Examples: "add author profiles", "book reviews", "library membership permissions" +- **Review mode**: The user asks to review, audit, or check existing schema changes. + Examples: "review", "review schema changes", "audit the diff" + +--- + +## Mode 1: Design (Interactive) + +This is an iterative, conversational process. Do NOT skip steps or rush to a final answer. The goal is a thoroughly vetted SDL proposal that the user explicitly approves. + +### Step 1: Understand + +Before proposing anything, ask clarifying questions. You need to understand: + +- **What does the client need to DO?** Not what data exists — what operations matter. +- **Who consumes this?** Internal frontend, mobile app, third-party integrators, all of the above? +- **What existing types relate?** Read the current schema to find types that overlap or connect. +- **What are the edge cases?** Empty states, permissions boundaries, error conditions. + +Ask 3-5 targeted questions. Do not proceed until you have clear answers. + +### Step 2: Propose SDL + +Draft the schema changes in SDL notation. For each new or modified type, mutation, or query: + +1. Write the SDL with inline comments explaining non-obvious decisions. +2. Read and validate against these reference checklists (load only the ones relevant to the proposal): + - [Naming conventions](references/naming.md) — type, field, enum, argument naming rules + - [Mutation design](references/mutations.md) — input/payload patterns, granularity, batch operations + - [Nullability](references/nullability.md) — decision tree for nullable vs non-null, list matrix + - [Connections & pagination](references/connections.md) — when to paginate, cursor design, edge fields + - [Error handling](references/errors.md) — typed union errors and the shared `Error` interface + - [Type design](references/types.md) — abstract types, shared types, authorization exposure + - [Schema evolution](references/evolution.md) — safe vs dangerous vs breaking changes + +Present the SDL proposal clearly, grouped by: new types, modified types, new queries, new mutations. + +### Step 3: Grill + +This is the most important step. Challenge every design decision like a thorough API reviewer. Do NOT just propose and move on. Push back on: + +**Nullability:** + +- "Why is this field nullable? What does `null` mean to the client?" +- "This is non-null — are you certain you can always resolve it? What about partial failures?" +- "This list is `[T]` — can it contain nulls? Should it be `[T!]` or `[T!]!`?" + +**Type sharing & coupling:** + +- "This reuses type X from a different domain — they will diverge. Should this be a separate type?" +- "This input type is shared across mutations — what happens when one mutation needs an extra field?" + +**Mutation design:** + +- "This mutation is too broad — it updates 5 fields. What action is the client actually performing?" +- "This is a boolean toggle — should these be two separate mutations (enable/disable)?" +- "Where is the error type? What can go wrong?" + +**Field design:** + +- "Boolean argument — should this be an enum? Will there be a third state?" +- "This is a bare list — will it grow unbounded? Should it be a connection?" +- "This field name is generic — will it collide when the type is extended?" + +**Evolution:** + +- "Does this replace an existing field? Where is the deprecation?" +- "Can this type be extended later without breaking changes?" +- "Are you making a non-null commitment you might regret?" + +Keep pushing until there are no unresolved design questions. Every nullable field should have a documented reason. Every mutation should have clear error states. Every list should have a pagination decision. + +### Step 4: Iterate + +Based on the discussion: + +1. Update the SDL proposal with agreed-upon changes. +2. Re-validate against the relevant reference checklists. +3. Highlight what changed and why. +4. If new questions arise, return to Step 3. + +Repeat Steps 3-4 until the design is tight. + +### Step 5: Approve + +Present the final SDL proposal with: + +1. **Complete SDL** — all types, queries, mutations, subscriptions. +2. **Decision log** — key decisions made during the design with rationale. +3. **Evolution notes** — what future changes this design enables or constrains. +4. **Breaking changes** — if any, with migration path. + +**STOP HERE.** Do not proceed to implementation. Ask the user to explicitly approve the design before any code is written. The SDL is a contract — treat it as one. When the user approves, hand off to the `graphql-backend` skill for implementation. + +--- + +## Mode 2: Change Review + +Reactive audit of existing schema changes against the current branch. + +### Step 1: Gather the diff + +Run `git diff main` filtered to schema files (`.graphql`, `schema.*`) to see what changed. If no schema files are found in the diff, tell the user and ask them to point you to the relevant files. + +### Step 2: Load reference checklists + +Read ALL of these — a review must be comprehensive: + +- [Naming conventions](references/naming.md) +- [Mutation design](references/mutations.md) +- [Nullability](references/nullability.md) +- [Connections & pagination](references/connections.md) +- [Error handling](references/errors.md) +- [Type design](references/types.md) +- [Schema evolution](references/evolution.md) + +### Step 3: Audit every change + +For each added, modified, or removed schema element, check against every applicable rule. Be thorough — a review that misses issues is worse than no review. + +Classify each finding: + +**Issues** — Rule violations that should be fixed before merging: + +- Naming convention violations +- Missing error types on mutations +- Breaking changes without a deprecation path +- Unbounded lists without pagination +- Non-null fields that may not always resolve + +**Warnings** — Trade-offs the author should explicitly acknowledge: + +- Nullable fields without a documented reason +- Shared types across domains +- Broad mutations that could be split +- Fields that constrain future evolution + +**Good** — Patterns done well (reinforce good habits): + +- Consistent naming +- Thoughtful nullability choices +- Proper connection pagination +- Clean error handling + +### Step 4: Report + +Present findings in this format: + +``` +## Schema Review: [branch or feature name] + +### Issues (must fix) +1. **[Category]**: Description — reference to rule — suggested fix + +### Warnings (acknowledge) +1. **[Category]**: Description — trade-off to document + +### Good (well done) +1. **[Category]**: What was done well + +### Summary +[1-2 sentences: overall assessment and top priority action] +``` + +--- + +## Design Principles + +These principles guide both modes. They are non-negotiable. + +1. **Design for the client, not the database.** The schema models what clients need to do, not how data is stored. If you find yourself mirroring table columns, stop. + +2. **Make impossible states impossible.** Use enums over booleans, non-null grouping over nullable fields, unions over type flags. The type system should prevent invalid states. + +3. **Nullable by default, non-null by conviction.** Making a field non-null is a permanent commitment. You can always tighten later; you cannot loosen without breaking clients. + +4. **Every mutation has a clear verb.** `updateUser` is suspicious — what is the client actually doing? `renameUser`, `deactivateUser`, `changeUserEmail` are actions. + +5. **Every list needs a pagination decision.** Will this list grow unbounded? If yes or maybe, use a connection. If guaranteed bounded (enum values, roles), a plain list is fine. + +6. **Schema changes are contracts.** Adding a field is a promise to maintain it. Removing one breaks that promise. Treat every change with the weight it deserves. + +7. **Continuous evolution over versioning.** Add the new field, deprecate the old one with reason and sunset date, migrate clients, then remove. Never version a GraphQL API. + +--- + +## Reference File Index + +These files contain the detailed rules, decision trees, and examples. They are loaded on-demand — only read what is relevant to the current task. + +| Reference | When to load | +| ------------------------------------------------------ | --------------------------------------- | +| [references/naming.md](references/naming.md) | Any new type, field, enum, or argument | +| [references/mutations.md](references/mutations.md) | Any new or modified mutation | +| [references/nullability.md](references/nullability.md) | Any field nullability decision | +| [references/connections.md](references/connections.md) | Any list field or collection | +| [references/errors.md](references/errors.md) | Any mutation or error handling | +| [references/types.md](references/types.md) | Any new type, interface, union, or enum | +| [references/evolution.md](references/evolution.md) | Any modification to existing schema | diff --git a/.agents/skills/graphql-schema-design/references/connections.md b/.agents/skills/graphql-schema-design/references/connections.md new file mode 100644 index 0000000..e055dfb --- /dev/null +++ b/.agents/skills/graphql-schema-design/references/connections.md @@ -0,0 +1,435 @@ +# Connections & Pagination Reference + +Comprehensive reference for designing paginated lists in GraphQL using the +Relay Connection specification. All examples use pure GraphQL SDL with no +framework-specific details. + +--- + +## 1. When to Use Connections vs Simple Lists + +Not every list needs pagination. Use this decision tree: + +``` +Could this list grow unbounded over the lifetime of the API? + YES --> Connection pagination. Always. + NO --> + Is the maximum count small and guaranteed (<= ~20 items)? + YES --> Simple list [T!]! is acceptable. + NO --> Use Connection pagination to be safe. +``` + +**Simple list examples** (bounded, small, stable): +- Enum-like value sets (e.g., available currencies) +- Tags on an entity (typically bounded by policy) +- Rule definitions or configuration entries with hard caps + +**Connection examples** (unbounded, growing, queryable): +- Products, orders, users, comments +- Audit logs, events, notifications +- Search results of any kind + +When in doubt, use a connection. Converting a simple list to a connection later +is a breaking change. Converting a connection to a simple list (if you somehow +need to) is also breaking. Start with the connection if growth is plausible. + +--- + +## 2. Relay Connection Structure + +The Relay Connection specification defines three types that work together: +the Connection, the Edge, and PageInfo. + +### Full SDL + +```graphql +type Query { + products( + first: Int + after: String + last: Int + before: String + ): ProductConnection! +} + +type ProductConnection { + """The list of edges (items with cursor and relationship metadata).""" + edges: [ProductEdge!]! + + """Pagination metadata.""" + pageInfo: PageInfo! +} + +Both `[T!]!` (non-null outer) and `[T!]` (nullable outer) are valid conventions +for `edges` and `nodes`. Some frameworks and projects prefer the nullable outer +form. Either is correct — do **not** flag one as an issue when reviewing. Only +flag it if the choice is inconsistent within the same schema. + +type ProductEdge { + """Opaque cursor for this item's position in the list.""" + cursor: String! + + """The item at the end of this edge.""" + node: Product! +} + +type PageInfo { + """Whether more items exist when paginating forward.""" + hasNextPage: Boolean! + + """Whether more items exist when paginating backward.""" + hasPreviousPage: Boolean! + + """Cursor of the first item in the current page. Null when empty.""" + startCursor: String + + """Cursor of the last item in the current page. Null when empty.""" + endCursor: String +} +``` + +### Why each piece exists + +| Type | Purpose | +|------|---------| +| `Connection` | Container for the paginated result. Holds edges, pageInfo, and optional aggregate fields. | +| `Edge` | Wraps each item with its cursor and any relationship-specific metadata. | +| `PageInfo` | Tells the client whether more pages exist and provides cursors for the next request. | + +--- + +## 3. Connection Type Naming + +Connection and Edge types should be **unique per context**, never shared across +unrelated fields. + +```graphql +# CORRECT -- unique connection types per relationship context +type Team { + members(first: Int, after: String): TeamMemberConnection! +} + +type Organization { + users(first: Int, after: String): OrganizationUserConnection! +} + +# WRONG -- shared UserConnection hides context-specific differences +type Team { + members(first: Int, after: String): UserConnection! +} +type Organization { + users(first: Int, after: String): UserConnection! +} +``` + +Why: each connection context may need different edge fields (e.g., `role` on +`TeamMemberEdge` vs `joinedAt` on `OrganizationUserEdge`). A shared connection +type prevents this evolution. + +**Naming convention:** +- Connection type: `{Context}{Entity}Connection` (e.g., `TeamMemberConnection`) +- Edge type: `{Context}{Entity}Edge` (e.g., `TeamMemberEdge`) +- PageInfo: shared `PageInfo` type (standard across all connections) + +--- + +## 4. Edge Fields for Relationship Metadata + +Relationship-specific data belongs on the **edge**, not on the node. The node +represents the entity itself; the edge represents its membership in this +particular collection. + +```graphql +# CORRECT -- role describes the relationship, not the user +type TeamMemberEdge { + cursor: String! + node: User! + role: TeamMemberRole! + joinedAt: DateTime! +} + +# WRONG -- pollutes the User type with context-specific fields +type User { + id: ID! + name: String! + teamRole: TeamMemberRole # Only meaningful in team context +} +``` + +More examples of edge fields: +- `CollectionProductEdge.position` -- sort position within that collection +- `RepositoryCollaboratorEdge.permission` -- READ, WRITE, ADMIN +- `StargazerEdge.starredAt` -- when the user starred the repository + +This keeps node types clean, reusable, and free from contextual pollution. + +--- + +## 5. totalCount Considerations + +`totalCount` tells clients how many items exist in total (before pagination). +It seems useful but carries real costs. + +```graphql +type ProductConnection { + edges: [ProductEdge!]! + pageInfo: PageInfo! + totalCount: Int # Nullable -- intentionally +} +``` + +### Guidelines + +- **Do not add totalCount by default.** Only add it when there is a concrete + client need (e.g., displaying "Showing 1-10 of 342"). +- **Make it nullable** if you add it. This allows the server to omit it when + computation is too expensive (very large collections, distributed data). +- **Computing count is expensive.** A `COUNT(*)` on large tables can be slow. + For distributed or sharded data, it may require fan-out queries. +- **Once added, it is nearly impossible to remove.** Removing a field is a + breaking change. Clients will depend on it. +- **Consider alternatives:** `hasNextPage` is sufficient for infinite-scroll UIs. + An estimated count may work for display purposes without exact guarantees. + +--- + +## 6. Empty Connection Behavior + +A connection with no results is **not null**. It is a non-null connection with +empty edges and null cursors. + +```graphql +# Correct response for an empty connection +{ + "products": { + "edges": [], + "pageInfo": { + "hasNextPage": false, + "hasPreviousPage": false, + "startCursor": null, + "endCursor": null + } + } +} +``` + +### Rules + +- The connection field itself is **non-null** (`ProductConnection!`). +- `edges` returns an empty list `[]`, never null. +- `pageInfo.startCursor` and `endCursor` are null (no items = no cursors). +- `hasNextPage` and `hasPreviousPage` are both `false`. + +**Semantic distinction:** +- `null` connection = error or unauthorized access (the field itself failed) +- Empty connection = zero matching items (valid, successful result) + +--- + +## 7. Cursor Design + +Cursors are opaque tokens that identify a position in a paginated list. Clients +must never construct or parse them. + +### Principles + +1. **Opaque to clients.** Use Base64 encoding as a convention that signals + "do not parse this." Clients treat cursors as opaque strings. + +2. **Stable across inserts.** Inserting new items should not invalidate + existing cursors. Cursor-based pagination avoids the duplicate/skip + problems of offset pagination precisely because cursors point to a + specific position, not a numeric offset. + +3. **Self-contained.** A cursor should contain enough information for the + server to resume pagination without additional context. Typical internal + format: `base64("type:id:sort_key")`. + +4. **Include routing info for distributed systems.** If data is sharded or + partitioned, encode the shard/partition identifier in the cursor so the + server can route the next request correctly. + +```graphql +# Example cursor values (Base64-encoded, opaque to client) +# Internal: "product:42:2024-01-15T10:30:00Z" +# Encoded: "cHJvZHVjdDo0MjoyMDI0LTAxLTE1VDEwOjMwOjAwWg==" +``` + +**Never** document the cursor format in your API documentation. The moment +clients parse cursors, you lose the ability to change the format. + +--- + +## 8. Bidirectional Pagination + +The Relay spec supports pagination in both directions using two pairs of +arguments. + +### Forward pagination + +```graphql +products(first: 10, after: "cursor_abc") +``` +- `first`: how many items to return from the front +- `after`: return items after this cursor + +### Backward pagination + +```graphql +products(last: 10, before: "cursor_xyz") +``` +- `last`: how many items to return from the end +- `before`: return items before this cursor + +### Rules + +- Clients should **not combine** `first` with `last` in a single request. + The behavior is undefined or confusing in most implementations. +- `first`/`after` is the primary pattern for infinite-scroll and "load more" UIs. +- `last`/`before` enables reverse navigation (e.g., "show most recent first" + when the natural sort is ascending). +- `hasNextPage` is meaningful for `first`/`after` pagination. +- `hasPreviousPage` is meaningful for `last`/`before` pagination. + +```graphql +type Query { + """ + Paginate forward with first/after, backward with last/before. + Do not combine first with last in the same request. + """ + messages( + first: Int + after: String + last: Int + before: String + ): MessageConnection! +} +``` + +--- + +## 9. Filter & Sort Arguments on Connections + +Filter and sort arguments go on the **connection field**, alongside the +pagination arguments. + +### Simple filters: inline arguments + +When filtering is simple (one or two fields), use inline arguments with +default values: + +```graphql +type Query { + products( + first: Int + after: String + status: ProductStatus = ACTIVE + orderBy: ProductSortKey = CREATED_AT_DESC + ): ProductConnection! +} + +enum ProductSortKey { + CREATED_AT_ASC + CREATED_AT_DESC + NAME_ASC + NAME_DESC + PRICE_ASC + PRICE_DESC +} +``` + +### Complex filters: dedicated input type + +When filtering involves multiple fields or nested conditions, use a dedicated +input type: + +```graphql +type Query { + products( + first: Int + after: String + filter: ProductFilter + orderBy: ProductSortKey = CREATED_AT_DESC + ): ProductConnection! +} + +input ProductFilter { + status: ProductStatus + categoryId: ID + minPrice: Float + maxPrice: Float + searchTerm: String +} +``` + +### Guidelines + +- **Always provide default sort values** via GraphQL default syntax so clients + get deterministic ordering without specifying it. +- **Document what the default filter is.** If `status` defaults to `ACTIVE`, + say so in the field description. +- Filter/sort arguments should not affect cursor stability -- the same cursor + under the same filter/sort should return the same position. + +--- + +## 10. Custom Connection Fields + +Beyond the standard `edges` and `pageInfo`, connections can expose additional +convenience fields. + +### The `nodes` shortcut + +Many APIs provide a `nodes` field that returns items directly, skipping the +edge wrapper. This is useful when edge metadata is not needed. + +```graphql +type ProductConnection { + edges: [ProductEdge!]! + nodes: [Product!]! # Convenience: skips edge wrapper + pageInfo: PageInfo! +} + +# Client can query either way: +query { + # When edge metadata is needed + products(first: 10) { + edges { + cursor + node { name } + addedAt # Edge-specific field + } + } +} + +query { + # When only the items matter + products(first: 10) { + nodes { name } + pageInfo { hasNextPage endCursor } + } +} +``` + +**Always provide both `edges` and `nodes`** if you add the shortcut. The +`edges` pattern remains essential for relationship metadata. + +### Other custom fields + +- `totalCount: Int` -- covered in section 5 +- Domain-specific aggregations (use sparingly): + +```graphql +type OrderConnection { + edges: [OrderEdge!]! + nodes: [Order!]! + pageInfo: PageInfo! + totalCount: Int + """Sum of all order totals matching the current filter.""" + totalAmount: Money +} +``` + +Be cautious with aggregate fields. They have the same performance and +removal concerns as `totalCount`. Only add them when there is a proven +client need. diff --git a/.agents/skills/graphql-schema-design/references/errors.md b/.agents/skills/graphql-schema-design/references/errors.md new file mode 100644 index 0000000..46bcae9 --- /dev/null +++ b/.agents/skills/graphql-schema-design/references/errors.md @@ -0,0 +1,352 @@ +# GraphQL Error Handling — Typed Errors Pattern + +The standard error pattern for all mutation domain errors. + +All examples use pure GraphQL SDL — no framework-specific code. + +--- + +## Two Error Categories + +Every error falls into one of two categories. Getting this right determines +where the error surfaces. + +### Infrastructure Errors → `errors` key (top-level) + +Exceptional failures the **client developer** handles. The end user sees +"something went wrong." + +Examples: timeout, rate limit, auth failure, malformed query, internal error. + +```json +{ + "errors": [ + { + "message": "Could not connect to product service.", + "locations": [{ "line": 6, "column": 7 }], + "path": ["viewer", "products", 1, "name"], + "extensions": { "code": "SERVICE_CONNECT_ERROR" } + } + ] +} +``` + +- Always present without client opt-in +- The errored field resolves to `null`; null bubbles up to nearest nullable ancestor +- Not part of the type system — not introspectable +- **Never model these in the schema** + +### Domain Errors → Typed errors on the mutation payload (in schema) + +Expected business-rule failures the **end user** sees and acts on. + +Examples: username taken, password too weak, insufficient stock, duplicate name. + +- Fully typed, introspectable, discoverable +- Clients query them like any other data +- **Always model these in the schema using the typed errors pattern** + +--- + +## The Typed Errors Pattern + +The pattern = **Payload wrapper** + **`errors` union list** + **Error interface** + +### Complete SDL + +```graphql +type Mutation { + signUp(input: SignUpInput!): SignUpPayload! +} + +input SignUpInput { + email: String! + username: String! + password: String! +} + +# Payload wrapper — entity (nullable) + typed error list +type SignUpPayload { + "The created account. Null when errors is non-null." + account: Account + "Domain errors. Null means success; non-null means failure." + errors: [SignUpError!] +} + +# Union makes all possible error types discoverable via introspection +union SignUpError = + | UsernameTakenError + | PasswordTooWeakError + | EmailAlreadyRegisteredError + +# Shared interface — ALL union members MUST implement this +interface Error { + "Human-readable description of what went wrong." + message: String! + "Stable machine-readable identifier. Clients match on this, never on message." + code: ErrorCode! + """ + Path to the input field that caused the error. + Example: ["input", "items", "0", "quantity"] + Null when not attributable to a specific field. + """ + path: [String!] +} + +# Concrete error types: interface fields + case-specific fields +type UsernameTakenError implements Error { + message: String! + code: ErrorCode! + path: [String!] + suggestedUsername: String! +} + +type PasswordTooWeakError implements Error { + message: String! + code: ErrorCode! + path: [String!] + passwordRules: [String!]! + minimumLength: Int! +} + +type EmailAlreadyRegisteredError implements Error { + message: String! + code: ErrorCode! + path: [String!] +} +``` + +### Client Query + +```graphql +mutation Register($input: SignUpInput!) { + signUp(input: $input) { + account { + id + username + } + errors { + # Known cases — rich handling + ... on UsernameTakenError { + message + suggestedUsername + } + ... on PasswordTooWeakError { + message + passwordRules + } + # Catch-all — handles ANY error, including future ones + ... on Error { + message + code + path + } + } + } +} +``` + +### Why This Pattern + +| Property | How | +|---|---| +| **Discoverable** | Union members visible via introspection | +| **Multiple errors** | `errors` is a list | +| **Custom fields per error** | Each concrete type has own fields | +| **Forward compatible** | `... on Error` catch-all for new types | +| **Consistent contract** | Shared `message` + `code` + `path` | +| **Partial data + errors** | Payload has both entity and errors | + +--- + +## Rules Checklist + +- [ ] Every mutation uses a unique payload type with `errors: [{MutationName}Error!]` +- [ ] Every error union is unique per mutation: `SignUpError`, `CreateProductError` +- [ ] ALL error union members MUST implement the shared `Error` interface +- [ ] The entity field on the payload is nullable (null when errors present) +- [ ] The errors list is nullable with non-null items: `[T!]` (null = success, non-null = errors) +- [ ] Error types never shared across mutations (they will diverge) +- [ ] Enforce the interface requirement with a linter + +--- + +## Error Interface Design + +The shared interface is what makes this pattern forward-compatible. Define it once; +all error types across all mutations implement it. + +```graphql +interface Error { + "Human-readable description." + message: String! + "Stable machine-readable identifier. Clients switch on this." + code: ErrorCode! + "Path to the input field that caused the error. Null if not field-specific." + path: [String!] +} +``` + +Concrete types implement the interface and add case-specific fields: + +```graphql +type ProductNameTakenError implements Error { + message: String! + code: ErrorCode! + path: [String!] + suggestedName: String! +} + +type InsufficientStockError implements Error { + message: String! + code: ErrorCode! + path: [String!] + availableQuantity: Int! + requestedQuantity: Int! +} +``` + +--- + +## Error Code Enum + +Error codes are **stable identifiers** that client code switches on. Messages +are for humans; codes are for machines. + +```graphql +enum ErrorCode { + # Generic + VALIDATION_ERROR + NOT_FOUND + UNAUTHORIZED + FORBIDDEN + CONFLICT + + # Identity / Auth + EMAIL_ALREADY_REGISTERED + PASSWORD_TOO_WEAK + USERNAME_TAKEN + + # Catalog + PRODUCT_NAME_TAKEN + INSUFFICIENT_STOCK + + # Billing + QUOTA_EXCEEDED + PAYMENT_DECLINED +} +``` + +- Use `SCREAMING_SNAKE_CASE` +- Group by domain area with comments when the enum grows +- Never reuse a code for a different meaning. Deprecate, don't rename. +- Generic codes cover broad cases; add domain-specific codes only when + clients need distinct handling + +--- + +## Validation Errors + +Validation errors point the client at the exact input field that failed. +They are a specialized concrete type implementing the `Error` interface. + +```graphql +type ValidationError implements Error { + message: String! + code: ErrorCode! + "Path from input root to the invalid field. e.g. ['input', 'email']" + path: [String!]! + "Machine-readable constraint violated. e.g. 'maxLength:255', 'format:email'" + constraint: String +} +``` + +Guidelines: + +- `path` starts from the mutation input root so clients can map to form fields +- Return ALL validation errors at once — don't fail on the first one +- `constraint` is optional metadata for rich UI (e.g., showing max length) + +### Example Response + +```json +{ + "data": { + "createProduct": { + "product": null, + "errors": [ + { + "message": "Name must be at most 255 characters.", + "code": "VALIDATION_ERROR", + "path": ["input", "name"] + }, + { + "message": "Price must be a positive integer.", + "code": "VALIDATION_ERROR", + "path": ["input", "price"] + } + ] + } + } +} +``` + +--- + +## Partial Success (Batch Mutations) + +Batch mutations may succeed for some items and fail for others. Return both. + +```graphql +type BatchCreateProductsPayload { + "Products that were successfully created." + products: [Product!]! + "Errors for items that failed, with index references." + errors: [BatchItemError!]! +} + +type BatchItemError implements Error { + message: String! + code: ErrorCode! + path: [String!] + "Zero-based index of the failed item in the input list." + index: Int! +} +``` + +Guidelines: + +- Never fail an entire batch because one item is invalid +- `index` lets clients map errors back to specific input items +- For ID-keyed operations, use `itemId: ID!` instead of `index` +- Document whether successful items are committed when some fail + +--- + +## Forward Compatibility + +Adding a new error type to a union is a **dangerous schema change**. Clients +that exhaustively match known types get an empty response for new types. + +### The Interface Catch-All + +The `Error` interface solves this. Clients include one catch-all fragment: + +```graphql +errors { + ... on UsernameTakenError { message suggestedUsername } + # Catches ANY error type, including ones added later + ... on Error { message code path } +} +``` + +If `AccountSuspendedError` is added next month, existing clients match it +through `... on Error` and display the message without a client update. + +### Guidelines + +- [ ] All error types implement the `Error` interface — no exceptions +- [ ] Document that clients MUST include `... on Error { message code }` as catch-all +- [ ] Never remove or change the meaning of an existing `ErrorCode` value +- [ ] When adding a new union member, announce it and give clients time for + richer handling if they want it diff --git a/.agents/skills/graphql-schema-design/references/evolution.md b/.agents/skills/graphql-schema-design/references/evolution.md new file mode 100644 index 0000000..d733287 --- /dev/null +++ b/.agents/skills/graphql-schema-design/references/evolution.md @@ -0,0 +1,429 @@ +# GraphQL Schema Evolution Reference + +A standalone reference for evolving GraphQL schemas without versioning. +Covers change classification, deprecation workflows, communication strategies, +and safe removal procedures. + +--- + +## 1. Change Classification + +### Safe (Non-Breaking) Changes + +These changes are backward-compatible and can be deployed without risk to +existing clients: + +- Add a new type (object, input, enum, union, interface, scalar) +- Add a new field to an existing object type +- Add a new nullable argument to an existing field +- Add a new argument with a default value +- Add a new nullable field to an input type (with default value) +- Make a nullable output field non-null (strengthening the guarantee) +- Make a non-null input argument nullable (relaxing the requirement) +- Add `@deprecated` to a field or enum value +- Add a new directive definition +- Add a new directive usage (on types, fields, etc.) +- Add descriptions or modify existing descriptions on any schema element +- Reorder fields within a type (field order is not semantically meaningful) + +### Dangerous Changes (May Break Some Clients) + +These are technically additive but can break clients that make assumptions +about completeness: + +| Change | Why It Is Dangerous | +|---|---| +| Add a new **enum value** | Clients with exhaustive switch/match statements will hit an unhandled case | +| Add a new **union member** | Clients using inline fragments without a fallback will silently drop the new member | +| Add a new **interface implementation** | Same risk as union member -- clients not handling unknown concrete types break | +| Change a field's type to a **more specific subtype** | Clients relying on the original type shape may not handle the subtype correctly | +| Add a **required (non-null) field** to an input type | All existing operations that construct this input will fail validation | +| Change a **default value** on an argument | Clients relying on the previous default behavior will see different results silently | + +### Breaking Changes + +These changes will cause existing valid operations to fail: + +| Change | Impact | +|---|---| +| Remove a type | Any query referencing the type fails validation | +| Remove a field from an object type | Any query selecting that field fails validation | +| Remove an argument from a field | Any query passing that argument fails validation | +| Remove an enum value | Any query or variable using that value fails validation | +| Remove a union member | Inline fragments on the removed member silently return nothing | +| Remove an interface implementation | Same as union member removal | +| Rename a field | Equivalent to remove + add -- old name breaks, new name is unknown | +| Rename a type | Equivalent to remove + add | +| Make a non-null output field nullable | Clients expecting non-null will encounter unexpected nulls at runtime | +| Make a nullable input argument non-null | Existing operations omitting the argument fail validation | +| Change a field's return type (not to a subtype) | Clients parsing the old type shape will break | +| Change an argument's type | Existing operations passing the old type fail validation | +| Remove a directive that clients depend on | Queries using the directive fail validation | + +--- + +## 2. The Add-Deprecate-Migrate-Remove Workflow + +Every non-additive schema change follows a four-step lifecycle: + +### Step 1: Add + +Introduce the replacement alongside the existing element. Both old and new +must coexist in the schema. + +```graphql +type User { + name: String! # existing + firstName: String! # new replacement + lastName: String! # new replacement +} +``` + +### Step 2: Deprecate + +Mark the old element with `@deprecated` including a complete deprecation +message (see Section 3). This signals tooling and developers that the field +is going away. + +```graphql +type User { + name: String! @deprecated(reason: """ + Field `name` is being split into `firstName` and `lastName`. + Use `firstName` and `lastName` instead. + Sunset date: 2027-01-15. + See: https://dev.example.com/changelog/user-name-split + """) + firstName: String! + lastName: String! +} +``` + +### Step 3: Migrate + +Actively help clients move to the replacement: + +- Send targeted notifications to affected integrators (identified via query analytics) +- Update documentation and code samples to use the new field +- Provide migration guides with before/after examples +- Monitor usage analytics -- track the decline of deprecated field usage +- Perform brownouts if usage persists past the warning period (see Section 6) + +### Step 4: Remove + +Once usage has reached zero (or an acceptable breakage threshold), remove the +deprecated element from the schema. Follow the Pre-Removal Verification Steps +(Section 7) before deploying. + +```graphql +type User { + firstName: String! + lastName: String! +} +``` + +--- + +## 3. Deprecation Message Template + +Every deprecation message must include four components: + +``` +@deprecated(reason: """ + {Reason why the field is being removed or changed}. + Use `{alternative field or approach}` instead. + Sunset date: {YYYY-MM-DD}. + See: {URL to migration guide or changelog entry} +""") +``` + +### Full Example + +```graphql +type Product { + imageUrl: String @deprecated(reason: """ + Field `imageUrl` is being replaced by the `image` object type + which supports multiple resolutions and alt text. + Use `image { url }` instead. + Sunset date: 2027-06-01. + See: https://dev.example.com/changelog/product-image-type + """) + image: ProductImage +} +``` + +### Component Breakdown + +| Component | Purpose | Example | +|---|---|---| +| **Reason** | Explains *why* the change is happening | "Split into separate fields for i18n support" | +| **Alternative** | Tells the client *what to use instead* | "Use `firstName` and `lastName` instead" | +| **Sunset date** | Gives clients a *concrete deadline* | "Sunset date: 2027-06-01" | +| **Link** | Points to *detailed migration instructions* | "See: https://dev.example.com/changelog/..." | + +--- + +## 4. Enhanced Deprecation Helper Pattern + +When many developers contribute to a schema, enforce consistency with a +deprecation helper function. On large schemas with many contributors, this +keeps deprecation quality uniform. + +### Concept + +Create a helper that requires all four deprecation components and formats +them consistently: + +```graphql +# The helper signature (pseudocode -- implement in your language of choice): +# +# deprecationReason( +# reason: String, +# alternative: String, +# sunsetDate: Date, +# link: String +# ) -> String + +# Usage produces a formatted deprecation message: +# +# "Name is going away. Use `username` instead. +# Sunset date: 2027-05-01. +# For more information: https://dev.example.com/blog/deprecation-name" +``` + +### Benefits + +- **Enforces completeness**: Developers cannot deprecate without providing all + four components -- the function signature requires them +- **Consistent formatting**: Every deprecation message follows the same structure + regardless of who wrote it +- **Machine-parseable**: Automated tools can extract sunset dates and alternatives + from the structured format +- **Review-friendly**: Pull request reviewers can verify deprecation quality at + a glance + +### Schema Introspection Result + +The formatted message appears in the `deprecationReason` field when clients +introspect the schema: + +```graphql +{ + __type(name: "User") { + fields(includeDeprecated: true) { + name + isDeprecated + deprecationReason + # Returns: "Name is going away. Use `username` instead. + # Sunset date: 2027-05-01. + # For more information: https://..." + } + } +} +``` + +--- + +## 5. Communication Strategy + +Deprecation directives alone are insufficient. Reach clients through multiple +channels: + +### Targeted Emails (Highest Impact) + +GraphQL's per-field usage tracking enables targeted communication. Instead of +blasting all integrators, email only those whose queries use the deprecated +field. Include: + +- Which field is deprecated and why +- What to use instead (with code examples) +- The sunset date +- A link to the full migration guide + +### Changelog + +Maintain a public changelog that documents every schema change with: + +- Date of change +- Classification (safe / dangerous / breaking) +- Affected types and fields +- Migration instructions + +### Documentation Site + +- Update all code examples to use the replacement fields +- Add migration guides with before/after query examples +- Mark deprecated fields visually in API reference docs (most GraphQL + documentation generators handle this automatically via `@deprecated`) + +### Blog Posts + +For significant changes that affect many clients, publish a detailed blog post: + +- Explain the motivation behind the change +- Walk through the migration step by step +- Provide a timeline with key dates +- Offer support channels for questions + +### Developer Dashboard + +If you operate a developer portal, surface deprecation warnings directly in +the dashboard: + +- Show which of the developer's registered queries use deprecated fields +- Display countdown to sunset date +- Provide one-click access to migration guides + +--- + +## 6. Brownout Checklist + +Brownouts are temporary, scheduled disruptions to deprecated fields that force +remaining clients to notice and act. Use them as a last resort before permanent +removal. + +1. **Identify affected clients** -- Query analytics to find every client still + using the deprecated field, including frequency and criticality of their usage + +2. **Send targeted notifications** -- Contact each affected integrator directly + with the brownout schedule, migration instructions, and support contacts + +3. **Schedule the brownout window** -- Choose a low-traffic period (e.g., 1 hour + on a weekday morning). Announce the exact time in advance + +4. **Disable the field during the window** -- Return a descriptive error message + instead of data: + ```json + { + "errors": [{ + "message": "Deprecated: Field `name` has been removed. Use `firstName` and `lastName` instead. See: https://dev.example.com/changelog/user-name-split" + }] + } + ``` + Implement via feature flags, schema visibility controls, or resolver-level + checks + +5. **Monitor usage after brownout** -- Check analytics: did usage of the + deprecated field drop? Did affected clients migrate? + +6. **Repeat with increasing duration** -- If usage persists, schedule longer + brownouts (2 hours, 4 hours, 8 hours, full day) until usage reaches zero + or an acceptable threshold + +7. **Proceed to final removal** -- Once brownouts confirm usage has ceased, + follow the Pre-Removal Verification Steps (Section 7) and remove the field + permanently + +--- + +## 7. Pre-Removal Verification Steps + +Before removing a deprecated field from the schema, complete every step: + +1. **Verify the deprecation period** -- Confirm the field has been deprecated + for at least the full committed sunset period (e.g., 6 months). Do not + shorten this without explicit stakeholder approval + +2. **Check query analytics** -- Pull current usage data. Is any client still + sending queries that reference this field? Look at the last 30 days minimum + +3. **Confirm notification was sent** -- Verify that all affected integrators + were notified (targeted emails, brownout announcements). If any were missed, + notify them and extend the sunset period + +4. **Run schema diff** -- Use a schema comparison tool to confirm the change is + classified correctly (breaking vs. safe). Review the diff output to ensure + no unintended changes are included + +5. **Update schema snapshot tests** -- Run snapshot tests and update the + committed snapshots to reflect the removal. Review the diff to confirm only + the expected elements are removed + +6. **Update changelog and documentation** -- Add the removal to the changelog + with the date, reason, and replacement. Remove the deprecated field from all + code examples and migration guides + +7. **Deploy and monitor** -- Deploy the schema change and monitor error rates, + support tickets, and client health metrics for at least 24 hours. Have a + rollback plan ready + +--- + +## 8. Decision Tree: Avoiding Breaking Changes + +When you need to change existing schema behavior, walk through this decision +tree before resorting to deprecation and removal: + +``` +Need to change existing schema behavior? +| ++--> Can you ADD a new field/type alongside the existing one? +| | +| +--> YES: Add the new element. Deprecate the old one. +| | This is the safest and most common path. +| | +| +--> NO: Continue below. +| ++--> Can you add an ARGUMENT with a default value? +| | +| +--> YES: Add the argument with a default that preserves +| | current behavior. New clients pass the argument +| | to get the new behavior. Non-breaking. +| | +| +--> NO: Continue below. +| ++--> Can you WRAP the result in a new type? +| | +| +--> YES: Introduce a new type that contains both the old +| | shape and the new shape. Deprecate the old field, +| | point clients to the new type. +| | +| +--> NO: Continue below. +| ++--> Can you introduce a NEW FIELD with a different name? +| | +| +--> YES: This is a variant of "add alongside". Use a more +| | specific name for the new field. Deprecate the old. +| | +| +--> NO: The change is truly breaking. Enter the full +| Add-Deprecate-Migrate-Remove workflow (Section 2). +``` + +### Common Scenarios and Solutions + +| Scenario | Breaking Approach | Non-Breaking Alternative | +|---|---|---| +| Rename a field | Remove `name`, add `username` | Add `username`, deprecate `name` | +| Change return type | Change `image: String` to `image: Image` | Add `imageDetails: Image`, deprecate `image` | +| Split a field | Remove `name` | Add `firstName` + `lastName`, deprecate `name` | +| Add required input | Make `currency` non-null | Add `currency` as nullable with default, or add new input type | +| Remove enum value | Remove `LEGACY_STATUS` | Deprecate the value, map it to replacement server-side | +| Change argument type | Change `id: Int` to `id: ID` | Add new argument `entityId: ID`, deprecate `id` argument | +| Restructure a type | Rewrite `Address` fields | Add `addressV2: StructuredAddress`, deprecate old fields | + +### When Breaking Changes Are Unavoidable + +Some situations genuinely require breaking changes with no additive workaround: + +- **Security vulnerabilities**: A field leaks private data and must be removed + immediately +- **Performance emergencies**: An unpaginated list returns millions of records, + causing outages +- **Non-null contract violations**: A non-null field can actually return null at + runtime, causing client crashes +- **Authentication/authorization changes**: Fundamental auth mechanisms must + change (e.g., removing basic auth) + +In these cases, proceed directly to the deprecation workflow but with an +accelerated timeline. Communicate urgently and broadly. Security issues may +justify immediate removal without the standard sunset period. + +--- + +## Summary: The Evolution Mindset + +1. **Additive changes first** -- always look for a way to add rather than modify +2. **Deprecate before removing** -- never surprise clients with a removal +3. **Communicate through every channel** -- deprecation directives alone are not enough +4. **Measure before removing** -- query analytics are your source of truth +5. **Brownout before sunsetting** -- give stragglers one last chance +6. **Design for evolution from day one** -- overly specific names, nullable by default for outputs you are unsure about, object types over scalars for extensibility diff --git a/.agents/skills/graphql-schema-design/references/mutations.md b/.agents/skills/graphql-schema-design/references/mutations.md new file mode 100644 index 0000000..ee32748 --- /dev/null +++ b/.agents/skills/graphql-schema-design/references/mutations.md @@ -0,0 +1,563 @@ +# Mutation Design Patterns + +Comprehensive reference for designing GraphQL mutations. All examples use pure +GraphQL SDL -- no framework-specific code. + +--- + +## 1. Input Type Design + +### Core Rules + +- [ ] Every mutation gets a **unique input type** -- never shared across mutations +- [ ] Use a **single required input argument** per mutation (Relay convention) +- [ ] Input types should be **strongly typed** -- avoid all-nullable kitchen-sink inputs +- [ ] Name inputs `{MutationName}Input`: `CreateProductInput`, `AddItemToCheckoutInput` +- [ ] **Never share** input types between create and update mutations + +### Why Unique Inputs Per Mutation? + +Create mutations need **non-null** fields (you must supply a name to create a product). +Update mutations need **nullable** fields (you only send what changed). Sharing a single +`ProductInput` forces everything nullable, destroying type safety. + +```graphql +# CORRECT -- separate inputs with appropriate nullability +input CreateProductInput { + name: String! + price: Money! + categoryId: ID! +} + +input UpdateProductInput { + name: String + price: Money + categoryId: ID +} + +# WRONG -- shared input, everything nullable +input ProductInput { + name: String + price: Money + categoryId: ID +} +``` + +### Nested Input Types + +Group related fields into sub-inputs for clarity and reuse within a single mutation. + +```graphql +input CreateOrderInput { + items: [OrderItemInput!]! + shippingAddress: AddressInput! + billingAddress: AddressInput +} + +input AddressInput { + street: String! + city: String! + postalCode: String! + country: String! +} + +input OrderItemInput { + productId: ID! + quantity: Int! +} +``` + +### @oneOf for Exclusive Inputs + +When exactly one of several input fields should be provided, use `@oneOf` to express +mutual exclusivity in the schema. + +```graphql +input PaymentMethodInput @oneOf { + creditCard: CreditCardInput + bankTransfer: BankTransferInput + digitalWallet: DigitalWalletInput +} +``` + +### Boolean Arguments + +Replace boolean toggles with separate fields or separate mutations. + +```graphql +# WRONG +type Query { + posts(includeArchived: Boolean): [Post!]! +} + +# CORRECT -- explicit fields +type Query { + posts: [Post!]! + archivedPosts: [Post!]! +} +``` + +### Input Design Checklist + +- [ ] Single required input argument +- [ ] Unique type name matching the mutation name +- [ ] Non-null fields on create inputs, nullable on update inputs +- [ ] Nested sub-inputs for grouped fields (address, line items) +- [ ] `@oneOf` for mutually exclusive options +- [ ] No boolean flags -- use separate mutations or enum arguments instead + +--- + +## 2. Payload Type Design + +### Core Rules + +- [ ] Every mutation gets a **unique payload type** -- no sharing, no exceptions +- [ ] Name payloads `{MutationName}Payload`: `CreateProductPayload` +- [ ] Return the **mutated entity** so clients can update their cache +- [ ] Return **affected parent entities** that clients may need to refetch +- [ ] Include **typed error information** (via union or errors field) + +### Payload Structure + +```graphql +type CreateProductPayload { + product: Product # The mutated entity (nullable -- null on failure) + errors: [CreateProductError!] # Typed errors (null on success, non-null on failure) +} + +union CreateProductError = + | ValidationError + | DuplicateNameError + | UnauthorizedError +``` + +### Returning Affected Parents + +When a mutation changes child data, return the parent so the client can refetch +aggregate fields without a separate query. + +```graphql +type AddItemToCheckoutPayload { + checkoutItem: CheckoutItem # The new item + checkout: Checkout # Parent -- totalPrice, itemCount may have changed + errors: [AddItemToCheckoutError!] +} +``` + +### Returning the Query Root + +For mutations that affect broad application state, include a `query` field pointing +to the root query type. This lets clients refetch anything in a single round-trip. + +```graphql +type ImportProductsPayload { + importedCount: Int! + query: Query # Clients can refetch any top-level field + errors: [ImportProductsError!] +} +``` + +### Payload Design Checklist + +- [ ] Unique payload type per mutation +- [ ] Contains the mutated entity (nullable for failure cases) +- [ ] Contains affected parent entities when relevant +- [ ] Contains typed errors as a nullable list (null = success, non-null = errors) +- [ ] Consider `query: Query` field for broad-impact mutations + +--- + +## 3. Granularity Decision Tree + +Use this tree to decide whether a mutation should be coarse-grained (one call does +many things) or fine-grained (one call does one thing). + +``` +Is the client CREATING a new entity from scratch? +| ++-- YES --> Use a COARSE-GRAINED create mutation +| (one call creates the entity with all required data) +| Example: createCheckout(input: { email, items, address }) +| ++-- NO --> Is this an UPDATE or ACTION on an existing entity? + | + +-- YES --> Use FINE-GRAINED action mutations + | (each mutation does one named thing) + | Examples: addItemToCheckout, updateCheckoutAddress, + | removeItemFromCheckout + | + +-- Do 3+ fine-grained mutations NEED to succeed together? + | + +-- YES --> That is a USE CASE. Design a single coarser + | mutation for it. + | Example: completeCheckout (validates items, + | charges payment, creates order) + | + +-- NO --> Keep them separate and fine-grained. + +Secondary check: + +Does the UI action map to a SINGLE button or form submit? +| ++-- YES --> Consider a single mutation matching that action +| ++-- NO --> Fine-grained mutations per sub-action +``` + +### Rules of Thumb + +1. **Coarse creates, fine-grained updates.** Entities are often created in one step + but modified incrementally. +2. **Name the action, not the data.** `archiveBook` not `updateBook(archived: true)`. +3. **When clients manage partial failures, you went too fine.** If three mutations + must all succeed, that is one use case -- one mutation. +4. **Network cost matters.** While fine-grained is ideal, each mutation is a network + round-trip. Balance purity with practicality. + +--- + +## 4. Anemic Mutations + +### What Is an Anemic Mutation? + +An anemic mutation exposes raw data modification instead of meaningful domain +actions. It is the GraphQL equivalent of Martin Fowler's "Anemic Domain Model" -- +your schema becomes a dumb bag of data rather than expressing behaviors. + +### The Problem + +```graphql +# ANEMIC -- one giant mutation that sets raw fields +type Mutation { + updateCheckout(input: UpdateCheckoutInput): UpdateCheckoutPayload +} + +input UpdateCheckoutInput { + email: Email + address: Address + items: [ItemInput!] + creditCard: CreditCard + billingAddress: Address +} +``` + +**Why this is harmful:** + +- Clients must **guess** which combination of fields to send for a given action +- Everything is nullable, so the schema communicates **nothing** about requirements +- The server cannot return **specific errors** -- any field could have caused failure +- Business logic leaks to clients (e.g., "to add an item, also update totals") +- Adding a field to the input is a **silent contract change** -- existing clients break + +### The Fix: Action-Based Mutations + +```graphql +# ACTION-BASED -- each mutation is a named domain action +type Mutation { + addItemToCheckout(input: AddItemToCheckoutInput!): AddItemToCheckoutPayload! + removeItemFromCheckout(input: RemoveItemFromCheckoutInput!): RemoveItemFromCheckoutPayload! + updateCheckoutAddress(input: UpdateCheckoutAddressInput!): UpdateCheckoutAddressPayload! + applyDiscountToCheckout(input: ApplyDiscountInput!): ApplyDiscountPayload! +} + +input AddItemToCheckoutInput { + checkoutId: ID! + item: ItemInput! # Non-null -- this field is REQUIRED +} +``` + +**Benefits of action-based design:** + +- Schema is **strongly typed** -- nothing optional that should be required +- Clients know **exactly** what to provide for each action +- Server returns **specific error types** per action +- Impossible for clients to reach an inconsistent state +- Side effects (events, subscriptions) map cleanly to specific mutations + +### Detection Checklist + +Your mutation may be anemic if: + +- [ ] The input type has more than 5 nullable fields +- [ ] The mutation name starts with `update` and the input mirrors the entity shape +- [ ] Clients must send unrelated fields together to achieve one action +- [ ] You cannot describe what the mutation "does" in one sentence without saying "updates" +- [ ] The same mutation handles conceptually different operations (add, remove, modify) + +--- + +## 5. Batch and Transaction Patterns + +### The Problem with Sequential Mutations + +GraphQL executes root mutation fields sequentially, but there is **no transaction +boundary** across them. If a client sends: + +```graphql +mutation { + op1: addProductToCheckout(...) { id } + op2: addProductToCheckout(...) { id } + op3: addProductToCheckout(...) { id } +} +``` + +Then `op1` may succeed, `op2` may fail, and `op3` may succeed -- leaving the +client in an inconsistent state. Additionally, this requires dynamic query string +construction, which defeats static analysis tooling. + +### Solution A: Plural Mutations + +For operations on multiple items of the **same type**, design a plural mutation. + +```graphql +type Mutation { + addProductsToCheckout( + input: AddProductsToCheckoutInput! + ): AddProductsToCheckoutPayload! +} + +input AddProductsToCheckoutInput { + checkoutId: ID! + items: [CheckoutItemInput!]! +} + +input CheckoutItemInput { + productId: ID! + quantity: Int! +} +``` + +This solves both the transaction problem (all-or-nothing on the server) and the +static query problem (one mutation, variable number of items via input list). + +### Solution B: Operations List + +For **mixed operations** (add + remove + update in one call), use an operations +list with an enum discriminator. + +```graphql +type Mutation { + updateCartItems( + input: UpdateCartItemsInput! + ): UpdateCartItemsPayload! +} + +input UpdateCartItemsInput { + cartId: ID! + operations: [CartItemOperationInput!]! +} + +input CartItemOperationInput { + operation: CartItemOperation! + ids: [ID!]! +} + +enum CartItemOperation { + ADD + REMOVE +} +``` + +Usage: + +```graphql +mutation { + updateCartItems(input: { + cartId: "abc123" + operations: [ + { operation: ADD, ids: ["item1", "item2"] } + { operation: REMOVE, ids: ["item3"] } + ] + }) { + cart { + items { name } + } + } +} +``` + +### Solution C: Typed Operation Variants + +When different operations require **different inputs**, use separate optional fields +per operation type. Pair with `@oneOf` when available. + +```graphql +input CartItemOperationInput @oneOf { + add: CartItemAddInput + remove: CartItemRemoveInput + updateQuantity: CartItemUpdateQuantityInput +} + +input CartItemAddInput { + productId: ID! + quantity: Int! +} + +input CartItemRemoveInput { + itemId: ID! +} + +input CartItemUpdateQuantityInput { + itemId: ID! + quantity: Int! +} +``` + +### Batch Decision Checklist + +- [ ] Multiple items of same type? --> Plural mutation with list input +- [ ] Mixed operations that must be atomic? --> Operations list with enum discriminator +- [ ] Different input shapes per operation? --> Typed operation variants with `@oneOf` +- [ ] Clients building dynamic query strings? --> Redesign as single mutation with variables + +--- + +## 6. Async Mutation Patterns + +### When to Use + +Use async patterns when a mutation triggers work that takes longer than a +typical HTTP request timeout (bulk imports, payment processing, report generation, +external system orchestration). + +### The Job Pattern + +Return a `Job` type that clients can poll or subscribe to for completion. + +```graphql +type Mutation { + importProducts(input: ImportProductsInput!): ImportProductsPayload! +} + +type ImportProductsPayload { + job: Job + errors: [ImportProductsError!]! +} + +type Job { + id: ID! + status: JobStatus! + result: JobResult + createdAt: DateTime! + completedAt: DateTime + query: Query # Root query for post-completion refetching +} + +enum JobStatus { + PENDING + IN_PROGRESS + COMPLETED + FAILED + CANCELED +} + +union JobResult = ImportProductsResult | JobFailure + +type ImportProductsResult { + importedCount: Int! + skippedCount: Int! +} + +type JobFailure { + message: String! + code: String +} +``` + +### Discriminated State Pattern + +Model async states as a union of explicit types rather than a status enum. +Each state carries only the fields relevant to it. + +```graphql +union PaymentState = + | PendingPayment + | ProcessingPayment + | CompletedPayment + | FailedPayment + +type PendingPayment { + id: ID! + amount: Money! + createdAt: DateTime! +} + +type ProcessingPayment { + id: ID! + amount: Money! + processorTransactionId: String! +} + +type CompletedPayment { + id: ID! + amount: Money! + receipt: Receipt! + completedAt: DateTime! +} + +type FailedPayment { + id: ID! + amount: Money! + failureReason: String! + canRetry: Boolean! +} +``` + +### Async Mutation Checklist + +- [ ] Operation takes > 5 seconds? --> Return a Job, not the result +- [ ] Clients need to poll? --> Expose `job(id: ID!): Job` query field +- [ ] Clients need push updates? --> Expose `jobStatusChanged(jobId: ID!)` subscription +- [ ] Job completion triggers broad state change? --> Include `query: Query` on Job type +- [ ] States have different data shapes? --> Use discriminated union over status enum + +--- + +## 7. Anti-Pattern Reference + +| Anti-Pattern | Problem | Fix | +|---|---|---| +| **Kitchen-sink update** `updateCheckout(email, address, items, card)` | All fields nullable, clients guess which to send, no clear action semantics | Split into action-based mutations: `addItemToCheckout`, `updateCheckoutAddress` | +| **Shared input types** `ProductInput` for both create and update | Create needs non-null, update needs nullable -- shared type forces all nullable | Unique inputs: `CreateProductInput` (non-null) + `UpdateProductInput` (nullable) | +| **Generic CRUD update** `updateBook(archived: true)` | Hides domain actions behind data manipulation, weak error modeling | Action-based: `archiveBook`, `renameBook` | +| **Sequential dependent mutations** 3 mutations that must all succeed | Partial failures, client manages retries, inconsistent state | Single coarser mutation for the combined use case | +| **Dynamic query construction** Generating mutation aliases in a loop | Defeats static analysis, unpredictable at runtime, hard to cache | Plural mutation: `addProductsToCheckout(items: [ItemInput!]!)` | +| **Bare entity return** `createProduct: Product` | Cannot evolve return type, no error field, no affected-parent fields | Payload type: `CreateProductPayload { product, errors }` | +| **Void mutations** `deleteProduct: Boolean` | No entity to update in cache, no error detail, no undo info | Payload: `DeleteProductPayload { deletedProductId, errors }` | +| **Symmetric nullable CRUD** Same input for create/read/update/delete | Schema communicates nothing; each operation has different requirements | Separate mutation per action with appropriately typed inputs | + +--- + +## Quick Reference: Mutation Anatomy + +```graphql +# Complete well-designed mutation +type Mutation { + addItemToCheckout( + input: AddItemToCheckoutInput! # 1. Unique, required input + ): AddItemToCheckoutPayload! # 2. Unique payload +} + +input AddItemToCheckoutInput { # 3. Strongly typed, nothing optional + checkoutId: ID! # that should be required + item: CheckoutItemInput! +} + +input CheckoutItemInput { # 4. Nested input for grouped fields + productId: ID! + quantity: Int! = 1 # 5. Defaults document behavior +} + +type AddItemToCheckoutPayload { + checkoutItem: CheckoutItem # 6. Mutated entity (nullable on failure) + checkout: Checkout # 7. Affected parent + errors: [AddItemToCheckoutError!] # 8. Typed errors (null on success) +} + +union AddItemToCheckoutError = # 9. Specific error types + | CheckoutNotFoundError + | ProductNotFoundError + | OutOfStockError + | ValidationError +``` diff --git a/.agents/skills/graphql-schema-design/references/naming.md b/.agents/skills/graphql-schema-design/references/naming.md new file mode 100644 index 0000000..c189187 --- /dev/null +++ b/.agents/skills/graphql-schema-design/references/naming.md @@ -0,0 +1,380 @@ +# GraphQL Naming Conventions Reference + +Comprehensive checklist for naming types, fields, mutations, arguments, and enums +in a GraphQL schema. Derived from production conventions used by GitHub, Shopify, +and other large-scale GraphQL APIs. + +**Golden rule:** Consistency is king. When names are consistent, discovering new +parts of the API feels natural. When they are not, every new field is a guessing +game. + +--- + +## 1. Type Naming + +All type names use **PascalCase**. Suffixes communicate purpose at a glance. + +### Checklist + +- [ ] Every type name is PascalCase: `Product`, `OrderStatus`, `TeamMember` +- [ ] Input types end with `Input`: `CreateProductInput`, `UpdateAddressInput` +- [ ] Mutation payload types end with `Payload`: `CreateProductPayload`, `DeleteOrderPayload` +- [ ] Connection types end with `Connection`: `ProductConnection`, `TeamMemberConnection` +- [ ] Edge types end with `Edge`: `ProductEdge`, `TeamMemberEdge` +- [ ] Error types end with `Error`: `ProductNotFoundError`, `ValidationError` +- [ ] Interface names describe behavior as adjectives: `Starrable`, `Discountable`, `Commentable` +- [ ] Interface names never include the word "Interface": `Discountable` not `ItemInterface` +- [ ] Names are overly specific rather than generic: `TeamMember` not `User` when context is team membership +- [ ] Namespace types when ambiguity is possible: `BusinessCategory` not `Category` +- [ ] Generic names (`User`, `Event`, `Item`) are reserved for truly generic concepts + +### Examples + +```graphql +# Good -- specific, suffixed, PascalCase +type Product { ... } +type ProductConnection { ... } +type ProductEdge { ... } +input CreateProductInput { ... } +type CreateProductPayload { ... } +type ProductNotFoundError { ... } +interface Starrable { ... } + +# Bad -- vague, missing suffix, wrong casing +type product { ... } +type productInput { ... } +type CreateProductResponse { ... } +type ItemInterface { ... } +``` + +### Why specificity matters + +Using a generic name like `User` early on paints you into a corner. When the +schema needs to distinguish between a logged-in viewer and a team member, you +are forced into a large deprecation: + +```graphql +# Before -- too generic +type User { + name: String! + email: String! + permissions: [Permission!]! +} + +# After -- specific types that each expose the right fields +interface User { + name: String! +} + +type Viewer implements User { + name: String! + email: String! + permissions: [Permission!]! +} + +type TeamMember implements User { + name: String! + isAdmin: Boolean! +} +``` + +Specific naming avoids this migration and makes the API clearer for clients. + +### Real-world type naming + +| API | Pattern | Example | +|---------|----------------------------|----------------------------------------------| +| GitHub | Adjective interfaces | `Starrable`, `Assignable`, `Closable` | +| GitHub | Specific connection types | `TeamMemberConnection`, `IssueCommentConnection` | +| Shopify | Domain-specific types | `DraftOrder`, `FulfillmentEvent` | + +--- + +## 2. Field Naming + +All field names use **camelCase**. Fields should describe what they return, not +how they fetch it. + +### Checklist + +- [ ] Every field name is camelCase: `totalPrice`, `createdAt`, `hasNextPage` +- [ ] No `get` or `find` prefix on query fields: `products(ids:)` not `getProducts(ids:)` +- [ ] Fields are not namespaced within their parent type: on `BusinessAddress` use `formatted` not `formattedBusinessAddress` +- [ ] Boolean fields read as predicates with `is`, `has`, or `can` prefix: `isActive`, `hasNextPage`, `canDelete` +- [ ] Default values are used to document defaults: `products(sort: SortOrder = DESC)` +- [ ] Field names describe the returned data, not the implementation: `owner` not `ownerRecord` +- [ ] Plural names for list fields, singular for single-value fields: `tags` not `tag` for a list + +### Examples + +```graphql +type Product { + # Good + title: String! + isAvailable: Boolean! + hasVariants: Boolean! + tags: [String!]! + createdAt: DateTime! + + # Bad -- namespaced redundantly within parent + productTitle: String! + productIsAvailable: Boolean! +} + +type Query { + # Good -- no verb prefix, clean + products(first: Int, after: String): ProductConnection! + product(id: ID!): Product + + # Bad -- unnecessary verb prefix + getProducts(first: Int, after: String): ProductConnection! + findProduct(id: ID!): Product +} +``` + +### Consistency trap + +```graphql +# Inconsistent -- clients cannot predict the pattern +type Query { + products(ids: [ID!]): [Product!]! + findPosts(ids: [ID!]): [Post!]! +} +``` + +A client using `findPosts` will assume `findProducts` exists. When it does not, +they hit an error and lose trust in the API. Pick one pattern and apply it +everywhere. + +--- + +## 3. Mutation Naming + +Mutation names are **action-based** and read like imperative commands. They +describe what the operation *does*, not what data it touches. + +### Checklist + +- [ ] Mutations read as verb-first actions: `createBook`, `archiveBook`, `addBookToShelf` +- [ ] Consistent verb prefixes across the schema: if you `create` books, you `create` authors -- never mix `create`/`add`/`new` for the same concept +- [ ] Symmetric actions exist: `publishBook` implies `unpublishBook`; `addBook` implies `removeBook` +- [ ] Fine-grained update mutations name the specific action: `updateBookTitle`, `addBookToShelf`, `removeBookFromShelf` -- not a generic `updateBook` +- [ ] Mutations describe intent, not data shape: `archiveBook` not `updateBook(archived: true)` +- [ ] Batch mutations use plural nouns: `addBooksToShelf` not multiple `addBookToShelf` calls +- [ ] No REST verb leakage: no `postAuthor`, `putBook`, `deleteBookById` + +### Examples + +```graphql +type Mutation { + # Good -- action-based, consistent verbs, symmetric + createProduct(input: CreateProductInput!): CreateProductPayload! + updateProduct(input: UpdateProductInput!): UpdateProductPayload! + deleteProduct(input: DeleteProductInput!): DeleteProductPayload! + + publishPost(input: PublishPostInput!): PublishPostPayload! + unpublishPost(input: UnpublishPostInput!): UnpublishPostPayload! + + addItemToCart(input: AddItemToCartInput!): AddItemToCartPayload! + removeItemFromCart(input: RemoveItemFromCartInput!): RemoveItemFromCartPayload! + + # Bad -- generic update, REST verbs, inconsistent + updateCheckout(input: UpdateCheckoutInput!): UpdateCheckoutPayload! + postUser(input: PostUserInput!): PostUserPayload! + newProduct(input: NewProductInput!): NewProductPayload! +} +``` + +### Verb consistency table + +Pick one verb per concept and use it everywhere: + +| Concept | Good verb | Avoid mixing with | +|----------------------|-----------|--------------------------| +| Create new entity | `create` | `add`, `new`, `insert` | +| Modify existing | `update` | `edit`, `modify`, `patch` | +| Remove permanently | `delete` | `remove`, `destroy` | +| Add to collection | `add` | `create`, `attach` | +| Remove from collection | `remove` | `delete`, `detach` | +| Change state | Named action: `publish`, `archive`, `approve` | generic `update` | + +### Real-world mutation naming + +| API | Pattern | Example | +|---------|----------------------|--------------------------------------------------| +| Shopify | Noun-verb ordering | `productCreate`, `orderCancel`, `draftOrderComplete` | +| GitHub | Verb-first ordering | `createIssue`, `closeIssue`, `addReaction` | + +Both are valid; the key is internal consistency within a single schema. + +--- + +## 4. Argument Naming + +Arguments use **camelCase** and follow well-established conventions for pagination, +filtering, and sorting. + +### Checklist + +- [ ] All arguments are camelCase: `first`, `after`, `includeDeleted`, `orderBy` +- [ ] Pagination arguments follow Relay convention: `first`, `after`, `last`, `before` +- [ ] Filter arguments are descriptive: `where`, `status`, `query` +- [ ] Sort arguments use `orderBy` or `sortBy` consistently +- [ ] Input arguments for mutations are a single required object: `input: CreateProductInput!` +- [ ] ID arguments are named `id` (singular) or `ids` (plural), not `productId` at the query root level +- [ ] Boolean arguments are avoided when a separate field or enum is clearer + +### Examples + +```graphql +type Query { + # Good -- Relay pagination, descriptive filter + products( + first: Int + after: String + last: Int + before: String + query: String + status: ProductStatus + orderBy: ProductOrderField + ): ProductConnection! + + # Good -- simple ID lookup + product(id: ID!): Product + + # Bad -- non-standard pagination, boolean flag + products( + limit: Int + offset: Int + includeArchived: Boolean + ): [Product!]! +} +``` + +### Boolean argument trap + +Boolean arguments often signal that two separate fields would be cleaner: + +```graphql +# Avoid -- boolean flag creates a branching API +type Query { + posts(includeArchived: Boolean): [Post!]! +} + +# Prefer -- separate fields with clear intent +type Query { + posts: [Post!]! + archivedPosts: [Post!]! +} +``` + +--- + +## 5. Enum Naming + +Enum type names are **PascalCase**. Enum values are **SCREAMING_SNAKE_CASE**. + +### Checklist + +- [ ] Enum type names are PascalCase: `ProductStatus`, `SortOrder`, `OrderState` +- [ ] Enum values are SCREAMING_SNAKE_CASE: `IN_PROGRESS`, `SORT_DESC`, `ACTIVE` +- [ ] Enum type names do not include "Enum" suffix: `ProductStatus` not `ProductStatusEnum` +- [ ] Enum values are not prefixed with the type name: `ACTIVE` not `PRODUCT_STATUS_ACTIVE` +- [ ] Enum type names describe the dimension: `SortOrder` not `Sort`, `ProductStatus` not `Status` + +### Examples + +```graphql +# Good +enum OrderStatus { + PENDING + IN_PROGRESS + SHIPPED + DELIVERED + CANCELLED +} + +enum SortDirection { + ASC + DESC +} + +# Bad -- wrong casing, redundant prefix +enum orderStatus { + orderStatusPending + orderStatusShipped +} + +enum Sort { + Ascending + Descending +} +``` + +--- + +## 6. Consistency Rules + +These rules apply across every naming decision and are the most common source of +client frustration when violated. + +### Checklist + +- [ ] Same verb for the same concept everywhere: if `create` is used for one entity, it is used for all entities +- [ ] Same suffix for the same structural role: all inputs end with `Input`, all payloads end with `Payload` +- [ ] Symmetric operations exist in pairs: `publish`/`unpublish`, `add`/`remove`, `enable`/`disable` +- [ ] Domain terms are used consistently: if it is called `Post` in one place, it is not `BlogPost` or `Article` in another unless they are different concepts +- [ ] Field names for the same concept match across types: `createdAt` everywhere, not `createdAt` on one type and `dateCreated` on another +- [ ] Casing rules are never mixed: no `totalPrice` alongside `total_count` +- [ ] New names follow established patterns: before adding a field, check what similar fields are called + +### Consistency audit questions + +Before adding any new name to the schema, ask: + +1. Is there an existing name for this concept? Use it. +2. Does this follow the same casing and suffix rules as similar items? +3. If a client knows the pattern for entity A, can they predict this name for entity B? +4. Does this name have a symmetric counterpart that should also exist? + +--- + +## 7. Anti-Pattern Quick Reference + +| Anti-Pattern | Problem | Fix | +|---|---|---| +| `getProducts`, `findPosts` | Unnecessary verb prefix on queries | `products`, `posts` | +| `products` and `findPosts` in same schema | Inconsistent query naming | Pick one pattern for all | +| `addProduct` and `createPost` | Inconsistent mutation verbs | Use `create` or `add` consistently | +| `User` for both viewer and team member | Too-generic name blocks future evolution | `Viewer`, `TeamMember` with `User` interface | +| `formattedBusinessAddress` on `BusinessAddress` | Redundant namespace within parent type | `formatted` | +| `publishPost` without `unpublishPost` | Missing symmetric action | Add the symmetric mutation | +| `updateCheckout(email, items, card)` | God mutation that does everything | `updateCheckoutEmail`, `addItemToCheckout`, etc. | +| `postUser`, `putProduct` | REST verb leakage in mutations | `createUser`, `updateProduct` | +| `orderStatus_PENDING` | Enum value prefixed with type name | `PENDING` | +| `product_status` (snake_case enum type) | Wrong casing for enum type | `ProductStatus` | +| `isActive: String` | Boolean-sounding name with non-boolean type | Use `Boolean` or rename the field | +| `items` and `itemsList` in same schema | Inconsistent pluralization | Pick one pattern | +| `ItemInterface` | "Interface" in interface name | `Item` or adjective like `Purchasable` | +| `Status` (too generic enum name) | Name collision risk as schema grows | `OrderStatus`, `ProductStatus` | +| `updateBook(archived: true)` | Generic update hiding a state change | `archiveBook` named mutation | +| `Category` without namespace | Ambiguous when multiple domains have categories | `BusinessCategory`, `ProductCategory` | + +--- + +## Quick Reference Card + +| Element | Casing | Suffix / Convention | Example | +|--------------------|----------------------|----------------------------------|----------------------------| +| Object type | PascalCase | None | `Product` | +| Input type | PascalCase | `Input` | `CreateProductInput` | +| Payload type | PascalCase | `Payload` | `CreateProductPayload` | +| Connection type | PascalCase | `Connection` | `ProductConnection` | +| Edge type | PascalCase | `Edge` | `ProductEdge` | +| Error type | PascalCase | `Error` | `ProductNotFoundError` | +| Interface | PascalCase | Adjective form | `Starrable`, `Commentable` | +| Enum type | PascalCase | Descriptive dimension | `OrderStatus` | +| Enum value | SCREAMING_SNAKE_CASE | No type prefix | `IN_PROGRESS` | +| Field | camelCase | Predicate prefix for booleans | `isActive`, `totalPrice` | +| Argument | camelCase | Relay names for pagination | `first`, `after` | +| Mutation | camelCase | Verb-first action | `createProduct` | diff --git a/.agents/skills/graphql-schema-design/references/nullability.md b/.agents/skills/graphql-schema-design/references/nullability.md new file mode 100644 index 0000000..65ca03d --- /dev/null +++ b/.agents/skills/graphql-schema-design/references/nullability.md @@ -0,0 +1,289 @@ +# GraphQL Nullability Reference + +Comprehensive reference for nullability decisions in GraphQL schema design. + +--- + +## 1. Nullability Decision Tree + +Every field and argument in a GraphQL schema must have a nullability decision. +The correct choice depends on whether the position is **input** or **output**, +and on the runtime characteristics of the data behind it. + +``` +Is this an INPUT argument or field? +| ++-- YES +| | +| +-- Is this argument required for the operation to make sense? +| | | +| | +-- YES --> Non-null (String!, ID!, Int!) +| | | The operation cannot proceed without this value. +| | | +| | +-- NO --> Nullable (String, ID, Int) +| | Optional filters, sorting preferences, or arguments +| | added after initial release for backwards compatibility. +| | +| +-- Are you ADDING a new argument to an existing field? +| | +| +-- YES --> Nullable, or non-null with a default value +| | Making it required would break all existing clients. +| | +| +-- NO --> Apply the required/optional rule above. +| ++-- NO --> This is an OUTPUT field + | + +-- Is it a Boolean? + | | + | +-- YES --> Non-null (Boolean!) + | A nullable Boolean creates a tri-state (true/false/null) + | which is almost always a design smell. + | + +-- Is it a list field? + | | + | +-- YES --> See List Nullability Matrix (Section 2) + | + +-- Is it a simple scalar on an already-loaded parent object? + | | + | +-- YES --> Generally safe to make non-null (String!, Int!) + | | The parent is loaded; these fields come with it. + | | + | +-- NO --> Continue below + | + +-- Is it backed by a database association, network call, or external service? + | | + | +-- YES --> Almost always NULLABLE + | Anything involving I/O can fail: timeouts, transient errors, + | rate limits, partial outages. Nullable fields contain the + | blast radius. + | + +-- Is it an object type you are confident will never be null? + | | + | +-- YES --> Think twice before making non-null. + | | - Will this type be reused in other contexts? + | | - Could the backing architecture change? + | | - Non-null to nullable is a BREAKING change. + | | If still confident: non-null. Otherwise: nullable. + | | + | +-- NO --> NULLABLE + | + +-- Is this a mutation payload entity field? + | + +-- YES --> NULLABLE + Return null when the mutation fails; + use a non-null errors field to explain why. +``` + +--- + +## 2. List Nullability Matrix + +There are four possible list signatures in GraphQL. Each carries different +guarantees about both the list itself and its items. + +| Signature | List can be null? | Items can be null? | When to use | +|-----------|-------------------|--------------------|-------------| +| `[T!]!` | No | No | **Default for most lists.** Empty list means "none." Every item is guaranteed valid. Use this unless you have a specific reason not to. | +| `[T!]` | Yes | No | When the list itself might not be applicable. `null` means "this concept does not apply here"; `[]` means "applies but empty." Rare. | +| `[T]!` | No | Yes | When individual items might fail to resolve but the list always exists. Useful when loading a batch where some items error independently. Uncommon. | +| `[T]` | Yes | Yes | Maximum flexibility, minimum guarantees. Avoid in almost all cases. Only appropriate when both the list and individual items can independently be absent or fail. | + +**Rule of thumb:** Default to `[T!]!` for output types. Only deviate when there +is a genuine semantic distinction between a null list and an empty list, or when +individual items can independently fail. + +For input types, `[T!]!` is also the default for required list arguments. +Use `[T!]` (nullable outer) when the list argument is optional. + +--- + +## 3. Null Propagation (Null Bubbling) + +When a non-null field resolves to `null` at runtime, GraphQL does not simply +return `null` for that field. Instead, it triggers a **null propagation** chain +that can destroy surrounding data. + +### How it works + +``` +Step 1: A non-null field resolver returns null + --> GraphQL raises a field error + +Step 2: Since the field is non-null, null is not a valid value + --> The null "bubbles up" to the nearest NULLABLE parent field + +Step 3: That nullable parent field becomes null + --> The error is recorded in the response "errors" array + +Step 4: If ALL ancestors up to the root are also non-null + --> The ENTIRE "data" key becomes null +``` + +### Example + +```graphql +type Query { + shop(id: ID!): Shop # nullable -- acts as error boundary +} + +type Shop { + name: String! # non-null + topProduct: Product! # non-null +} + +type Product { + name: String! # non-null -- if this returns null... + price: Money # nullable +} +``` + +If `Product.name` unexpectedly returns `null`: + +1. `Product.name` is non-null, so `Product` cannot be represented. +2. `Shop.topProduct` is non-null, so `Shop` cannot be represented. +3. `Query.shop` is nullable -- **it becomes `null`**, stopping propagation. + +Result: +```json +{ + "data": { "shop": null }, + "errors": [{ "message": "Cannot return null for non-nullable field Product.name" }] +} +``` + +All valid data in `Shop` (including `Shop.name`) is lost because of one bad +field deep in the tree. + +### Key consequences + +- **One bad non-null field can destroy an entire subtree of valid data.** + Sibling fields, parent fields -- all wiped out up to the nearest nullable + ancestor. + +- **Nullable fields act as error boundaries.** They stop null propagation, + allowing the rest of the response to remain intact. + +- **Deeply nested non-null chains are dangerous.** A transient error anywhere + in the chain nullifies everything above it. + +- **Design nullable "checkpoints" at strategic levels.** Entity-level fields + on queries and connections are good candidates for nullable boundaries. + +### Blast radius comparison + +``` +All non-null from root: One transient error --> entire data: null +Nullable at entity level: One transient error --> one entity null, rest intact +Nullable at field level: One transient error --> one field null, rest intact +``` + +--- + +## 4. Breaking Change Matrix + +Nullability changes have different compatibility implications depending on +whether the field is in **input position** (arguments, input type fields) or +**output position** (object type fields, payload fields). + +### Output position (fields clients READ) + +| Change | Safe or Breaking? | Explanation | +|--------|-------------------|-------------| +| Nullable --> Non-null (`String` to `String!`) | **SAFE** | Clients gain a stronger guarantee. Code handling null still works with non-null values. | +| Non-null --> Nullable (`String!` to `String`) | **BREAKING** | Clients may not handle null. Code assuming a value is always present will fail. | +| Add new nullable field | **SAFE** | Existing queries are unaffected; clients opt in by selecting the field. | +| Add new non-null field | **SAFE** | Only affects clients who select it; existing queries unchanged. | +| Remove field | **BREAKING** | Clients selecting the field will get query validation errors. | + +### Input position (arguments and input fields clients WRITE) + +| Change | Safe or Breaking? | Explanation | +|--------|-------------------|-------------| +| Non-null --> Nullable (`String!` to `String`) | **SAFE** | Clients can still provide the value; it just becomes optional. | +| Nullable --> Non-null (`String` to `String!`) | **BREAKING** | Clients not providing the value will fail validation. | +| Add new nullable argument | **SAFE** | Existing operations continue to work; the argument defaults to null. | +| Add new argument with default value | **SAFE** | Existing operations use the default. | +| Add new required (non-null) argument | **BREAKING** | All existing operations missing this argument will fail. | +| Remove argument | **BREAKING** | Clients providing the argument will get validation errors. | + +### Summary rule + +``` +OUTPUT: strengthening guarantees is safe (nullable --> non-null) + weakening guarantees is breaking (non-null --> nullable) + +INPUT: relaxing requirements is safe (non-null --> nullable) + tightening requirements is breaking (nullable --> non-null) +``` + +The asymmetry exists because **output** is what clients consume (stronger is +better) while **input** is what clients provide (looser is better). + +--- + +## 5. Practical Guidelines Checklist + +### Output fields + +- [ ] **Boolean fields: always non-null.** A nullable Boolean creates a confusing + tri-state. If you need tri-state semantics, use an enum instead. + +- [ ] **List fields: default to `[T!]!`.** Only use nullable variants when null + carries distinct meaning from an empty list. + +- [ ] **Simple scalars on loaded parents: non-null.** If the parent object is + already resolved, its scalar attributes (name, title, createdAt) are safe + as non-null. + +- [ ] **Fields backed by I/O: nullable.** Database joins, service calls, network + fetches -- anything that can fail should be nullable to contain blast radius. + Entity reference fields resolved via DataLoaders (e.g., `client: Client`) + are expected nullable by convention and do **not** require a description + justifying their null semantics. Do not flag these as issues in reviews. + +- [ ] **Mutation payload entity fields: nullable.** The entity is null when the + mutation fails; the errors field explains why. + +- [ ] **ID fields: non-null.** An entity always has an identity. + +- [ ] **Timestamp fields (createdAt, updatedAt): non-null.** These are set at + creation/modification and always present. + +- [ ] **Connection fields: non-null.** Return an empty connection, not null. + Null connection signals error or unauthorized; empty means no items. + +### Input arguments + +- [ ] **Required arguments: non-null.** If the operation cannot proceed without + the value, mark it non-null. + +- [ ] **Optional arguments: nullable with sensible defaults.** Use GraphQL default + values to document the default: `products(sort: SortOrder = DESC)`. + +- [ ] **New arguments on existing fields: always nullable or with defaults.** + Adding a required argument is a breaking change. + +- [ ] **Create input fields: mostly non-null.** A new entity usually requires + its core fields. + +- [ ] **Update input fields: mostly nullable.** Only provided fields are updated; + null means "do not change." + +### Evolutionary safety + +- [ ] **When in doubt, start nullable.** You can always strengthen to non-null + later (safe change). Going the other direction is breaking. + +- [ ] **Non-null is a permanent commitment.** Once clients depend on a non-null + guarantee, removing it breaks them. + +- [ ] **Use descriptions to clarify null semantics.** Document whether null means + "not applicable," "not loaded," "error," or "no value." + +- [ ] **Place nullable "checkpoints" strategically.** Entity-level fields in + queries, connection node fields, and mutation payload entities are good + places for nullable boundaries that contain null propagation. + +- [ ] **Never make a field non-null just because it happens to always have a + value today.** Consider whether the backing system could evolve to produce + null in the future (new data sources, architecture changes, partial failures). diff --git a/.agents/skills/graphql-schema-design/references/types.md b/.agents/skills/graphql-schema-design/references/types.md new file mode 100644 index 0000000..f22262b --- /dev/null +++ b/.agents/skills/graphql-schema-design/references/types.md @@ -0,0 +1,515 @@ +# Type Design, Abstract Types, Sharing, and Authorization + +Reference for GraphQL type design patterns, abstract type usage, type sharing +anti-patterns, authorization strategies, and global identification. + +--- + +## Type Design Patterns + +### 1. Avoid Anemic Types + +Expose computed fields that clients actually need rather than forcing them to +derive values from raw data. + +```graphql +# WRONG -- forces client-side computation +type Product { + price: Money! + discounts: [Discount!]! + taxes: [Tax!]! +} + +# RIGHT -- server owns the computation +type Product { + price: Money! + discounts: [Discount!]! + taxes: [Tax!]! + totalPrice: Money! +} +``` + +**Why it matters:** +- Client-side logic goes stale when underlying data rules evolve +- Multiple clients reimplement the same formula with subtle differences +- If clients are computing something from multiple fields, that computation + belongs on the server as its own field + +### 2. Group Related Fields (Prefix Smell Detection) + +If multiple fields share a prefix, they belong in a nested object type. + +```graphql +# SMELL -- shared prefix "creditCard" +type Payment { + creditCardNumber: String + creditCardExp: String + creditCardCvv: String + giftCardCode: String +} + +# BETTER -- grouped into CreditCard type +type Payment { + creditCard: CreditCard + giftCardCode: String +} + +type CreditCard { + number: CreditCardNumber! + expiration: CreditCardExpiration! +} + +type CreditCardExpiration { + isExpired: Boolean! + month: Int! + year: Int! +} + +scalar CreditCardNumber +``` + +**Benefits of grouping:** +- Non-null guarantees within the group (if `creditCard` exists, all sub-fields exist) +- Better evolution path -- add fields to the nested type without polluting the parent +- Cleaner, more navigable schema + +**Rule of three:** Start flat, group when reaching three related fields with a +shared prefix. + +### 3. Custom Scalars + +Use custom scalars to add semantic value beyond what `String` or `Int` provide. + +| Scalar | Instead of | Benefit | +|--------------|-----------------|--------------------------------------| +| `DateTime` | `String` | Validation, parsing consistency | +| `Email` | `String` | Input validation, semantic clarity | +| `URL` | `String` | Format validation | +| `HTML` | `String` | Client knows to render as HTML | +| `Markdown` | `String` | Client knows to render as markdown | +| `Money` | `Int` / `Float` | Currency handling, precision | +| `UUID` | `String` | Format validation | + +Custom scalars enable consistent validation across the entire schema and +document format expectations via their descriptions. + +### 4. Expressive Schemas -- Make Impossible States Impossible + +Design types so the schema itself prevents inconsistent data. + +```graphql +# BAD -- allows impossible states (paid=true, amountPaid=null) +type Cart { + paid: Boolean + amountPaid: Money + items: [CartItem!]! +} + +# GOOD -- if payment exists, all payment fields are guaranteed present +type Cart { + payment: Payment + items: [CartItem!]! +} + +type Payment { + paid: Boolean! + amountPaid: Money! +} +``` + +**Additional principles:** + +- **Use enums for known value sets.** Never `type: String!` when values are + `APPAREL | FOOD | TOYS`. +- **Use default values** to document default behavior: + `products(sort: SortOrder = DESC)` rather than hiding the default in resolver + logic. +- **Avoid the `JSON` scalar.** Use typed structures. Unstructured data + forfeits all schema benefits. + +### 5. One Field, One Job + +Split ambiguous optional arguments into separate fields. + +```graphql +# WRONG -- what happens if both are provided? Neither? +type Query { + findProduct(id: ID, name: String): Product +} + +# RIGHT -- each field has a single, required argument +type Query { + productByID(id: ID!): Product + productByName(name: String!): Product +} +``` + +Adding multiple entry points to the same data is not wasteful in GraphQL. +Clients select only the fields they use, so additional fields add zero overhead +for clients that ignore them. A conditional described in a field's documentation +("pass id OR name") is a schema smell. + +### 6. Object References over IDs + +```graphql +# ANTI-PATTERN -- forces extra round-trip +type Collection { + imageId: ID +} + +# PATTERN -- traverse the graph +type Collection { + image: Image +} +``` + +- Clients select only the sub-fields they need -- no over-fetching concern +- This is GraphQL's core strength: traversing the graph in a single request +- Raw ID fields force clients into a second query to resolve the object + +--- + +## Abstract Types + +### 7. Interface vs Union -- Decision Tree + +``` +Do the types share common BEHAVIOR (actions/contracts)? + (all can be starred, all can be discounted, all are commentable) + | + YES --> Interface + | Name as adjective: Starrable, Discountable, Commentable + | Interface fields represent the shared behavior contract + | + NO + | + Do the types merely share some FIELDS but no common behavior? + | + YES --> Do NOT use an interface + | Use code-level composition/inheritance instead + | "ItemFields" or "ItemInterface" naming is a smell + | + NO + | + Could a field return completely disjoint types? + (search results: Products, Articles, Users) + | + YES --> Union + | Union = "bag of possible return types" + | No shared fields required + | + Is this for error handling (Success | Error1 | Error2)? + | + YES --> Union with Error interface + Combine union for exhaustive matching + + interface for base error fields +``` + +**Example -- interface for shared behavior:** +```graphql +interface CatalogEntry { + id: ID! + title: String! +} + +type PhysicalProduct implements CatalogEntry { + id: ID! + title: String! + weightGrams: Int! +} + +type DigitalProduct implements CatalogEntry { + id: ID! + title: String! + downloadUrl: URL! +} + +type SubscriptionPlan implements CatalogEntry { + id: ID! + title: String! + billingInterval: BillingInterval! +} +``` + +This eliminates nullable fields and makes impossible states impossible. +A flat `CatalogEntry` with optional `weightGrams`, `downloadUrl`, and +`billingInterval` allows illegal combinations the abstract version prevents. + +### 8. Interface Naming + +| Form | Use when | Examples | +|------------|---------------------------------------------|-----------------------------------| +| Adjective | Behavioral interface (actions/capabilities) | `Starrable`, `Discountable`, `Commentable`, `Assignable`, `Closable` | +| Noun | Entity-like interface (identity contracts) | `Node`, `Actor` | + +**Never use:** `ItemInterface`, `ItemFields`, `ItemInfo`, `ItemBase` -- +these names signal the interface exists for field sharing, not behavior. + +### 9. When NOT to Use Interfaces + +- The interface exists solely for **field sharing** (code reuse), not a + behavioral contract +- The name is awkward, or includes "Interface", "Fields", "Info", "Base" +- The implementing types will inevitably **diverge** in behavior +- You keep adding fields to the interface just to satisfy one implementor + +**Code reuse is not type reuse.** If your implementation language makes it +easy to share field definitions via helpers, composition, or inheritance -- use +those mechanisms. Do not reach for a GraphQL interface to solve a code +organization problem. + +### 10. Evolution Implications (Abstract Types are "Dangerous" to Extend) + +- Adding a new **union member** is a **dangerous change** -- clients may not + handle the new case +- Adding a new **interface implementation** is equally dangerous +- GraphQL has no built-in exhaustive matching enforcement +- Clients should always code defensively with a fallback for unknown types +- When using union error types, use an `Error` interface so unknown errors + still return at least `message`: + +```graphql +# Forward-compatible error handling +... on Error { message code } # Catches current AND future error types +``` + +Document that new types may be added. Warn clients they must handle +unknown variants gracefully. + +--- + +## Sharing Anti-Patterns + +### 11. Never Share Connection Types + +```graphql +# WRONG -- shared UserConnection +type Organization { + users: UserConnection! +} +type Team { + members: UserConnection! # Same type -- locked together forever +} + +type UserEdge { + node: User + # Can't add isTeamLeader here without it appearing on org users + # Can't add isOrganizationAdmin without it appearing on team members +} +``` + +```graphql +# RIGHT -- separate connection types per context +type Organization { + users: OrganizationUserConnection! +} +type Team { + members: TeamMemberConnection! +} + +type TeamMemberEdge { + node: User + role: TeamMemberRole! # Team-specific edge data +} + +type OrganizationUserEdge { + node: User + isAdmin: Boolean! # Org-specific edge data +} +``` + +Connections and edges carry relationship metadata. Different relationships +produce different metadata. Sharing a connection type locks both contexts to +the same edge shape forever. + +### 12. Never Share Input Types + +```graphql +# WRONG -- shared input +input ProductInput { + name: String # Must be nullable for update, but create needs it + price: MoneyInput +} + +type Mutation { + createProduct(input: ProductInput): CreateProductPayload + updateProduct(id: ID!, input: ProductInput): UpdateProductPayload +} +``` + +```graphql +# RIGHT -- separate inputs +input CreateProductInput { + name: String! # Required for creation + price: MoneyInput! # Required for creation +} + +input UpdateProductInput { + name: String # Optional for update + price: MoneyInput # Optional for update +} +``` + +Create inputs have more non-null fields because you cannot create without +required data. Sharing forces you to make everything nullable and push +validation to runtime -- exactly what the schema should handle for you. + +### 13. Never Share Payload Types + +Each mutation gets its own payload type. Always. No exceptions. + +Payloads diverge as mutations gain mutation-specific error cases, warnings, +or supplementary data. Sharing payloads creates the same lock-in as shared +connections. + +### 14. Code Reuse Does Not Equal Type Reuse + +| Reuse level | Appropriate? | Mechanism | +|---------------|---------------|----------------------------------------| +| Code | Yes | Helpers, composition, base classes | +| GraphQL type | Almost never | Separate types per context | + +When there is any doubt, the downsides of sharing outweigh the benefits. +The cost of splitting later is much higher than starting separate. + +--- + +## Authorization Patterns + +### 15. Object-Level vs Field-Level Authorization -- Decision Tree + +``` +Is this about API scopes (which resources a client can access)? + | + YES --> Object-level authorization + | Types map well to API scopes + | Scalar fields on a type share the same permissions + | Prevents the "hidden path" problem + | + NO + | + Is this about business rules (can this user perform this action)? + | + YES --> Domain layer, NOT the GraphQL layer + | Business rules must be consistent across all entry points + | GraphQL is one of many; rules belong in the application/domain layer + | + NO + | + Is there a field-specific permission (e.g., salary on Employee)? + | + YES --> Field-level authorization (as an exception) + Prefer restructuring first: + EmployeeProfile vs EmployeePrivateDetails + If unavoidable, authorize at field level + SUPPLEMENTARY to object-level auth, never a replacement +``` + +### 16. The Hidden Path Problem + +```graphql +# DANGEROUS -- field-level auth only +type Query { + adminThings: AdminOnlyType! @auth(scope: "admin") # Protected + product: Product! @auth(scope: "products") # Less protected +} + +type Product { + settings: AdminOnlyType! # UNPROTECTED via product path! +} +``` + +A client with `products` scope can reach `AdminOnlyType` through +`product.settings` without ever hitting the admin auth check. + +**Fix:** Authorize at the TYPE level. No matter how a client reaches +`AdminOnlyType`, the authorization check fires. + +### 17. Null vs Error for Unauthorized Access + +- Return **null** for unauthorized access -- do not leak resource existence +- A `node(id: ID!)` query returning null should be indistinguishable from + "does not exist" +- Never return `403 Forbidden` or authorization errors that confirm an + entity exists +- The type must be **nullable** to support this pattern (non-null + auth = + null propagation disaster that destroys sibling data) + +### 18. API Scopes vs Business Rules -- Separation of Concerns + +| Layer | Concern | Example | +|---------|--------------------------|------------------------------------------| +| GraphQL | API scope enforcement | "This API key can read products" | +| Domain | Business rule enforcement| "Only team admins can close issues" | + +- API scopes determine what parts of the graph a client can see +- Business rules determine what actions a user can perform +- Business rules belong in the domain/application layer, enforced identically + regardless of entry point (GraphQL, REST, queue handler, CLI) +- Never implement business authorization as GraphQL-layer checks + +--- + +## Global Identification + +### 19. Node Interface Pattern + +```graphql +interface Node { + id: ID! +} + +type Product implements Node { + id: ID! + name: String! +} + +type User implements Node { + id: ID! + login: String! +} + +type Query { + node(id: ID!): Node + nodes(ids: [ID!]!): [Node]! +} +``` + +- `Node` signals: "this object is persisted, has identity, and is refetchable" +- Major business entities should implement `Node` +- Value objects or transient data should NOT implement `Node` +- Enables client-side normalized caching and single-query refetching + +### 20. Opaque ID Design + +- IDs must be **opaque** -- clients must not parse, construct, or assume + structure +- Use Base64 encoding to signal opacity: + `base64("Product:12345")` produces `UHJvZHVjdDoxMjM0NQ==` +- Include routing info for distributed systems: `shop_id:type:entity_id` +- Consider prefixed opaque IDs for developer ergonomics (Slack pattern): + `P_abc123` for Products, `U_def456` for Users +- Never expose raw database IDs as the GraphQL `ID` + +**What to encode in the ID:** +- At minimum: `type_name:database_id` +- For distributed systems: any routing info needed to globally locate the node + (e.g., `shop_id:type_name:entity_id`) +- The goal is that `node(id: "...")` can resolve without any additional context + +--- + +## Quick Reference -- Anti-Pattern Table + +| Anti-Pattern | Fix | +|-----------------------------------------------|------------------------------------------------------| +| `findProduct(id: ID, name: String)` | Split: `productByID(id: ID!)` + `productByName(name: String!)` | +| `creditCardNumber` + `creditCardExp` on type | Nested `creditCard: CreditCard` object | +| Shared `UserConnection` across contexts | `TeamMemberConnection` + `OrganizationUserConnection`| +| `type: String!` for known value set | `type: ProductType!` with enum | +| `imageId: ID` on a type | `image: Image` -- traverse the graph | +| `ItemInterface` / `ItemFields` naming | Adjective form: `Purchasable`, `Discountable` | +| Auth on fields only, not types | Object-level auth prevents hidden path vulnerabilities| +| Shared `ProductInput` for create and update | `CreateProductInput` + `UpdateProductInput` | +| Shared payload types across mutations | One payload type per mutation, always | +| `JSON` scalar for structured data | Typed structures: `[MetaAttribute!]!` | diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000..2b7a412 --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.agents/skills \ No newline at end of file diff --git a/.hongdown.toml b/.hongdown.toml index 0f7d772..f9750aa 100644 --- a/.hongdown.toml +++ b/.hongdown.toml @@ -1,3 +1,9 @@ no_inherit = true include = ["*.md", "**/*.md"] -exclude = ["AGENTS.md", "CLAUDE.md", "node_modules/"] +exclude = [ + ".agents/skills/", + ".claude/skills/", + "AGENTS.md", + "CLAUDE.md", + "node_modules/", +] diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..f6f1c34 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "skills": { + "graphql-schema-design": { + "source": "ChilliCream/agent-skills", + "sourceType": "github", + "skillPath": "skills/graphql-schema-design/SKILL.md", + "computedHash": "daa7be0631ea37c02ea9e000cfabb867c84fae2e958445beb1f63521f602e448" + } + } +}