Install
$ agentstack add skill-mickeyyaya-refactoring-skills-graphql-grpc-api-patterns ✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.
Security review
✓ PassedNo issues found. Passed automated security review. · v0.1.0 How review works →
- ✓ Prompt-injection patterns
- ✓ Secret / credential exfiltration
- ✓ Dangerous shell & filesystem operations
- ✓ Untrusted network calls
- ✓ Known-malicious package signatures
What it can access
- ● Network access Used
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ✓ Environment & secrets No
- ✓ Dynamic code execution No
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
Verified badge
Passed review? Show it. Paste this badge into your README, it links to the public security report.
Reliability & compatibility
Declared compatibility
Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.
We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.
How agent discovery & health will work →About
GraphQL and gRPC API Patterns for Code Review
Overview
GraphQL and gRPC each solve different API design problems but share a common failure mode: schema or contract changes that break callers without warning. GraphQL's flexibility makes it easy to over-fetch, under-batch, and leak implementation details. gRPC's strict Protobuf contracts are powerful but unforgiving when field numbers or types are changed carelessly.
When to use: Reviewing GraphQL resolvers, schema migrations, Protobuf .proto files, gRPC service definitions, or any code that consumes or exposes these APIs; evaluating batching strategies, subscription implementations, or streaming service designs.
Quick Reference
| Pattern | Core Idea | Primary Red Flag | |---------|-----------|-----------------| | GraphQL Schema Design | Types, fields, nullability as API contract | Nullable fields everywhere; exposing DB columns directly | | Schema Evolution (GraphQL) | Deprecate over version; never remove live fields | Removing fields without @deprecated; adding breaking nullability changes | | N+1 / DataLoader | Batch per-field DB lookups into one query | Per-resolver DB call inside a list type; no DataLoader | | GraphQL Subscriptions | Real-time push via WebSocket/SSE | Subscriptions without auth checks; no connection cleanup | | Protobuf Design | Field numbering and naming are the wire contract | Reusing field numbers; renaming fields without alias | | gRPC Streaming | Four modes: unary, server, client, bidirectional | Streaming without deadline/cancel propagation | | Schema Evolution (gRPC) | Backward-compatible field additions only | Removing/renumbering fields; changing field types | | Breaking Change Detection | Automate with buf or protoc-gen-compat | Manual review only; no CI check on .proto changes |
Patterns in Detail
1. GraphQL Schema Design and Nullability
Red Flags:
- Every field is nullable — callers must null-check every level, and errors are invisible
- Schema mirrors DB tables — leaks storage concerns into the API contract
- Input types reused as output types — different validation needs collide
IDfields typed asStringorIntinconsistently — prevents global object identification
TypeScript (schema-first with graphql-tag):
// BEFORE — nullable everywhere; DB table exposed directly
type User {
id: String
name: String
email: String
internal_account_id: String # storage detail leaked
}
// AFTER — nullable only where absence is meaningful; IDs use scalar ID
type User {
id: ID!
name: String!
email: String!
# internal_account_id removed — not a public concern
createdAt: DateTime!
avatarUrl: String # nullable: user may not have set one
}
type Query {
user(id: ID!): User # nullable return: user may not exist
me: User! # non-nullable: if authed, always present
}
TypeScript (code-first with type-graphql):
@ObjectType()
class User {
@Field(() => ID)
id: string;
@Field()
name: string; // non-null by default in type-graphql
@Field({ nullable: true })
avatarUrl?: string;
}
Java (DGS framework):
@DgsComponent
public class UserDataFetcher {
@DgsQuery
public User user(@InputArgument String id) {
return userService.findById(id)
.orElseThrow(() -> new DgsEntityNotFoundException("User not found: " + id));
}
}
2. GraphQL Schema Evolution — Deprecation Over Versioning
Red Flags:
- Field removed without
@deprecateddirective and a migration period - New required (non-null) argument added to existing field — breaks existing queries
schema.designchanged by renaming a field — old queries fail silently- Breaking nullability change: field changed from
StringtoString!without client audit
Schema deprecation — TypeScript:
// WRONG: just delete the field
type User {
id: ID!
name: String!
# username removed — breaks all existing clients
}
// CORRECT: mark deprecated first; remove only after all callers migrate
type User {
id: ID!
name: String!
username: String @deprecated(reason: "Use `name` instead. Removed after 2026-06-01.")
}
Adding arguments safely (TypeScript):
// WRONG — adding required arg breaks existing callers
users(limit: Int!, offset: Int!): [User!]!
// CORRECT — new arguments must have defaults so old queries still work
users(limit: Int = 20, offset: Int = 0): [User!]!
Evolut schema with interface extension — Go (gqlgen):
// schema.graphqls
extend type Query {
# New field added — non-breaking; old queries unaffected
usersByOrg(orgId: ID!, limit: Int = 20): [User!]!
}
Cross-reference: review-api-contract — API versioning strategies and backward compatibility rules.
3. GraphQL N+1 Problem and DataLoader Batching
Red Flags:
- A resolver for a field on a list type makes a database call per item — N+1 queries
DataLoadercreated inside the resolver function (not per-request) — defeats batching- No DataLoader for any has-many or belongs-to relationship
Promise.allused correctly but still bypasses batching — parallel but not coalesced
TypeScript — N+1 before/after:
// BEFORE — N+1: 1 query for posts + N queries for each author
const PostResolver = {
author: async (post: Post) => {
return db.users.findById(post.authorId); // called once per post in list
},
};
// AFTER — DataLoader coalesces all author IDs into a single batch query
import DataLoader from 'dataloader';
function createLoaders() {
return {
userLoader: new DataLoader(async (ids) => {
const users = await db.users.findByIds([...ids]);
const userMap = new Map(users.map(u => [u.id, u]));
return ids.map(id => userMap.get(id) ?? new Error(`User not found: ${id}`));
}),
};
}
// Loader attached to request context — one instance per request, not per field
const PostResolver = {
author: (post: Post, _args: unknown, ctx: Context) => {
return ctx.loaders.userLoader.load(post.authorId);
},
};
Go (gqlgen + dataloaden):
// generated UserLoader batches by []string IDs
func (r *queryResolver) Posts(ctx context.Context) ([]*model.Post, error) {
posts, err := r.db.AllPosts(ctx)
if err != nil { return nil, err }
return posts, nil
}
// Per-field resolver uses the loader — no direct DB call
func (r *postResolver) Author(ctx context.Context, post *model.Post) (*model.User, error) {
return getLoaders(ctx).UserLoader.Load(post.AuthorID)
}
Java (Spring GraphQL + @BatchMapping):
@BatchMapping(typeName = "Post", field = "author")
public Flux authors(List posts) {
List ids = posts.stream().map(Post::getAuthorId).toList();
return userService.findAllByIds(ids); // single DB call for all posts
}
4. GraphQL Subscriptions and Real-Time Patterns
Red Flags:
- Subscription resolvers without authentication checks — open WebSocket endpoints
- No connection cleanup (unsubscribe / complete) — memory leaks under load
- Publishing to all subscribers from a single resolver — fan-out bottleneck
- Subscriptions sharing mutable state across connections — race conditions
- Missing heartbeat/keep-alive — stale connections accumulate
TypeScript (Apollo Server + graphql-ws):
// schema
type Subscription {
messageAdded(channelId: ID!): Message!
}
// resolver — subscription with auth guard and cleanup
const resolvers = {
Subscription: {
messageAdded: {
subscribe: async function* (_, { channelId }, ctx) {
if (!ctx.userId) throw new GraphQLError('Unauthorized', {
extensions: { code: 'UNAUTHORIZED' },
});
const channel = await validateChannelAccess(ctx.userId, channelId);
const iter = pubsub.asyncIterator(`MESSAGE_ADDED:${channel.id}`);
try {
for await (const payload of iter) {
yield payload;
}
} finally {
// cleanup runs when client disconnects
iter.return?.();
}
},
},
},
};
Go (gqlgen subscriptions):
func (r *subscriptionResolver) MessageAdded(ctx context.Context, channelID string) (
): Promise {
const cursor = db.orders.cursor({ userId: call.request.userId });
try {
for await (const order of cursor) {
if (call.cancelled) break; // respect client cancel
call.write(orderToProto(order));
}
call.end();
} catch (err) {
call.destroy(err as Error);
}
}
Go — bidirectional streaming with context propagation:
// proto: rpc Chat (stream ChatMessage) returns (stream ChatMessage);
func (s *ChatServer) Chat(stream pb.Chat_ChatServer) error {
ctx := stream.Context()
for {
msg, err := stream.Recv()
if err == io.EOF {
return nil // client signalled half-close
}
if err != nil {
return status.Errorf(codes.Internal, "recv: %v", err)
}
select {
case uploadMetrics(StreamObserver responseObserver) {
List buffer = new ArrayList<>();
return new StreamObserver<>() {
@Override public void onNext(MetricPoint point) { buffer.add(point); }
@Override public void onError(Throwable t) {
log.error("Upload stream error", t);
responseObserver.onError(t);
}
@Override public void onCompleted() {
SummaryResponse summary = metricsService.summarize(buffer);
responseObserver.onNext(summary);
responseObserver.onCompleted();
}
};
}
7. Schema Evolution and Breaking Change Detection
Red Flags:
- No
buforprotoc-gen-compatcheck in CI — breaking changes merged silently - GraphQL schema changes deployed without
graphql-inspectordiff in PR - Field type changed (e.g.,
int32toint64) — wire incompatible even if value fits - New non-null field added to input type without default — breaks existing callers
oneoffield added to existing message without backward-compat analysis
buf.yaml — CI breaking change detection:
version: v1
breaking:
use:
- FILE
lint:
use:
- DEFAULT
CI workflow snippet (GitHub Actions):
- name: Check Protobuf breaking changes
run: |
buf breaking --against '.git#branch=main'
GraphQL Inspector in CI — TypeScript project:
# Compare current schema against main branch schema
npx graphql-inspector diff \
'git:origin/main:schema.graphql' \
'./schema.graphql' \
--onUsage breaking
Go — safe proto message evolution:
// BEFORE (v1)
message SearchRequest {
string query = 1;
}
// AFTER (v2) — backward compatible additions
message SearchRequest {
string query = 1;
int32 max_results = 2; // new optional field — old clients send 0 (proto default)
repeated string filters = 3; // new repeated — old clients send empty list
}
Cross-reference: error-handling-patterns — fail-fast validation at schema boundaries.
8. Anti-Patterns and Code Review Signals
| Anti-Pattern | Description | Fix | |-------------|-------------|-----| | Resolver N+1 | Per-item DB call inside list resolver | Introduce DataLoader; batch by parent IDs | | God Query | Single GraphQL query fetches entire object graph | Paginate lists; use @defer for non-critical fields | | Schema Versioning | /graphql/v2 endpoint instead of deprecating fields | Add @deprecated; version at field level, not endpoint | | Nullable Everything | All fields nullable to "be safe" | Make fields non-null unless absence is meaningful; document why nullable | | Field Number Reuse | Deleted proto field number reassigned | Always reserve deleted field numbers and names | | Missing Deadline | gRPC call or stream without deadline/timeout | Set WithDeadline or WithTimeout on every outgoing call | | Streaming Without Cancel | Server stream ignores context.Done() | Select on ctx.Done() in every stream loop iteration | | Open Subscription | Subscription endpoint lacks auth guard | Check credentials in subscribe function before yielding | | Input = Output Type | Same GraphQL type used for mutations and queries | Separate UserInput for writes; User for reads | | proto required Fields | Using required in proto2 locks schema forever | Use optional (proto3 default); validate in application layer |
God Query — TypeScript fix with @defer:
// BEFORE — fetches entire social graph in one round-trip
query UserPage($id: ID!) {
user(id: $id) {
id name email
posts { id title body comments { id text author { name } } }
followers { id name avatarUrl }
}
}
// AFTER — critical data first; deferred sections stream in after
query UserPage($id: ID!) {
user(id: $id) {
id name email
... on User @defer(label: "posts") {
posts { id title }
}
... on User @defer(label: "followers") {
followers { id name }
}
}
}
Missing deadline — Go:
// WRONG — no deadline; server stream runs forever on slow response
conn, _ := grpc.Dial(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
client := pb.NewOrderServiceClient(conn)
stream, _ := client.ListOrders(context.Background(), req)
// CORRECT — deadline propagated; stream cancelled if server is slow
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
stream, err := client.ListOrders(ctx, req)
if err != nil {
return fmt.Errorf("ListOrders: %w", err)
}
Auth guard on subscription — Java (Spring GraphQL):
@SubscriptionMapping
public Flux messageAdded(@Argument String channelId,
@AuthenticationPrincipal UserDetails user) {
if (user == null) throw new AccessDeniedException("Authentication required");
return messagingService.subscribe(channelId, user.getUsername())
.doOnCancel(() -> messagingService.unsubscribe(channelId, user.getUsername()));
}
Cross-References
review-api-contract— API backward compatibility rules, versioning strategy, and contract testingarchitectural-patterns— Event-driven architecture and pub/sub models that underpin subscriptions and streamingerror-handling-patterns— Fail-fast validation at schema and Protobuf message boundaries; error propagation in streaming contextssecurity-patterns-code-review— Authentication on WebSocket/subscription endpoints; authorization in resolver contextobservability-patterns— Tracing across gRPC boundaries; subscription connection metrics and DataLoader cache hit rates
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: mickeyyaya
- Source: mickeyyaya/refactoring-skills
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet, be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.