Jiaxi Liu (Jesse)

UNSW Master of IT · Open to Software Development Roles

Full-stack & Backend Development · API Integration · Automation · Exploring AI Agents

Back to Blog

API Design Review: Authentication, Validation, RESTful APIs, and GraphQL

Returning JSON proves that a communication path works. Designing an API also means deciding who may call it, which fields to trust, what repeated submissions do, how concurrent edits interact, and how clients recover from failure.

This article follows an article management system: visitors can read published articles, authors can create and edit their own articles, and administrators can manage all articles. Each article has a title, body, category, author, and version number. REST and GraphQL will share the same business rules so their interface choices can be compared directly.

The TypeScript examples illustrate application boundaries. The Zod validation snippet can run independently in a TypeScript environment with Zod installed. Snippets involving articleService, a database, or authentication middleware are architectural sketches, not backend features already implemented by this blog.

1. Follow a request through the backend

Suppose an author submits a new article:

POST /api/articles HTTP/1.1
Authorization: Bearer <access_token>
Content-Type: application/json
 
{
  "title": "Understanding API design",
  "body": "A practical introduction to API boundaries.",
  "categoryId": "backend"
}

A typical request passes through several responsibilities:

Request arrives
  → Check size and Content-Type, then parse JSON
  → Verify credentials and identify the caller
  → Validate fields and produce a trusted input object
  → Load resources and check permissions and business rules
  → Perform the write in a transaction
  → Shape the public response and record outcome and duration

This is not a universal middleware order. A gateway might reject missing credentials before parsing a body. An update usually needs to identify an article before checking ownership. The essential property is that each layer has a clear responsibility and required checks happen before a write.

Keep three representations separate: the raw request body, validated application input, and the database entity. The client does not supply the authoritative authorId; the backend derives it from the authenticated caller. Likewise, an internal moderation note in a database row does not belong in a public response merely because it exists.

2. Authentication and authorization: identity is only the first check

2.1 Authentication versus authorization

Authentication identifies the caller. Authorization decides whether that caller may perform an operation on a particular resource. Signing in as Alice must not grant permission to edit Bob's article.

type Actor = {
  id: string;
  role: "author" | "admin";
};
 
type ArticleOwner = {
  authorId: string;
};
 
function canEditArticle(actor: Actor, article: ArticleOwner): boolean {
  return actor.role === "admin" || article.authorId === actor.id;
}

The actor must come from a verified server-side identity, not a role field in the request body. In a multi-tenant system, the policy must also check tenant boundaries; a role name alone does not establish permission across organizations.

Hiding an Edit button improves the interface but does not enforce a policy. Direct HTTP requests, bulk endpoints, and GraphQL resolvers must all respect the same resource permissions. Hard-to-guess object IDs are not a substitute for authorization.

2.2 Cookies, sessions, bearer tokens, and JWTs describe different layers

These terms are often presented as competing alternatives, but they answer different questions:

Concept Question it answers Example
Cookie How does a browser store and automatically send data? Carrying an opaque session ID
Session How does the server maintain login state? Mapping an ID to a user, expiry, and revocation state
Bearer How is possession of a token used to access a service? Authorization: Bearer ...
JWT How are claims structured and their integrity protected? Signed identity and permission claims

A cookie can carry a session ID or a JWT. A bearer token can be a JWT or an opaque string that the server must look up. Credential format, transport, and invalidation strategy are separate design decisions.

2.3 How session-based login works

One browser-oriented design verifies login credentials, generates a random session ID, stores session state in a database or Redis, and sends the ID to the browser in a cookie.

Set-Cookie: __Host-session=<opaque_session_id>; Path=/; Secure; HttpOnly; SameSite=Lax

Later requests carry that cookie, allowing the server to look up the session. Logout revokes or deletes the server-side session and clears the browser cookie. Removing only frontend state does not invalidate a copied credential.

Secure restricts transmission to secure connections, HttpOnly blocks direct script access to the cookie, and SameSite constrains some cross-site sending behavior. Sessions also need expiration, rotation after login, and server-side invalidation. See the OWASP session management guide.

Because browsers attach cookies automatically, state-changing endpoints need appropriate CSRF defenses, such as origin checks and CSRF tokens. HttpOnly does not stop injected scripts from making requests as the user. CORS does not replace authentication or a complete CSRF defense. See OWASP's CSRF prevention guidance.

This design suits browser applications that centrally manage login state. Its cost includes making session validation available to the services that need it; the architecture extends beyond a login route.

2.4 JWT: decoding is not verification

A typical signed JWT has three parts:

base64url(header).base64url(payload).signature

Its payload is normally readable: signing is not encryption. Do not put passwords or other secrets in it, and do not treat a role decoded by the frontend as a server-side authorization decision.

Use a maintained library to verify signatures and allowed algorithms, then validate the expected issuer iss, audience aud, expiry exp, and applicable time constraints such as nbf. A valid signature alone can still describe a token intended for another system. See the OWASP REST security guide.

Short-lived JWTs can reduce session lookups, but permission claims may become stale. If an administrator loses their role, an existing token might still contain it. The design must balance short lifetimes, revocation, permission versions, and current-state checks for sensitive operations. Stateless verification does not mean the entire authentication system has no state.

2.5 Access tokens, refresh tokens, and retries

An access token is presented to an API. A refresh token is presented to the authorization service to obtain new access credentials; it should not be used as the bearer token for ordinary business requests.

When access credentials expire, a client may refresh once and resume the operation according to its policy. Not every 401 is refreshable. Stop when refresh fails, and coordinate simultaneous failures so multiple requests do not independently reuse an old refresh token.

Refresh token rotation replaces a token after successful use and can help detect reuse. Protection requirements depend on the client; the OAuth security best practice includes rotation or sender-constraining requirements for public clients. See RFC 9700.

Before retrying a write, determine whether the original operation might already have executed. An explicit authentication rejection is different from a connection disappearing after submission. The latter needs business-level duplicate protection, not an unconditional second create request.

2.6 OAuth 2.0, OIDC, Basic authentication, and API keys

OAuth 2.0 concerns authorization to access resources. OpenID Connect adds an authentication layer. A description of “OAuth login” should therefore identify whether identity is established through OIDC or a provider-specific user information API. An ID token is intended for the login client and should not replace an API access token. See the OpenID explanation.

Understand the authorization code flow as a sequence: redirect to the provider, let the user authenticate and authorize, receive a code, exchange it for tokens, and establish the application's own login state. Use a maintained implementation that handles PKCE, redirect URI validation, and applicable state and nonce checks. A hand-built flow that handles only successful login leaves essential behavior undefined.

Basic authentication encodes a username and password in a request header. Base64 provides no confidentiality, so HTTPS is required. It does not automatically solve credential rotation or fine-grained permissions. API keys commonly identify an application or project and need scopes, quotas, rotation, and revocation; they do not inherently identify the current end user.

For our article system, browser sessions could serve the management interface while restricted API credentials serve integrations. The popularity of JWTs is not a reason to force every caller into the same credential design.

3. Input validation: turn unknown data into explicit application input

3.1 TypeScript types do not validate network input

The following assertion does not check the incoming value:

type CreateArticleInput = {
  title: string;
};
 
const input = JSON.parse('{"title": 42}') as CreateArticleInput;
// TypeScript accepts this, but input.title is still a number at runtime.

Compiled JavaScript does not reject a numeric title because a TypeScript type says otherwise. Request bodies, query parameters, and external service responses need runtime checks at their trust boundaries.

3.2 Define field rules with a schema

The following Zod example defines create, partial update, and pagination inputs. Unknown-field behavior should be deliberate: these strict objects allow only the listed fields. See Zod basic usage and its schema API.

import { z } from "zod";
 
const TitleSchema = z.string().trim().min(1).max(120);
 
const CreateArticleSchema = z.object({
  title: TitleSchema,
  body: z.string().min(1).max(50_000),
  categoryId: z.string().min(1).max(64),
}).strict();
 
const UpdateArticleSchema = CreateArticleSchema.partial().refine(
  (input) => Object.values(input).some((value) => value !== undefined),
  { message: "At least one field is required" },
);
 
const LimitSchema = z.string()
  .regex(/^[1-9]\d{0,2}$/)
  .default("20")
  .transform(Number)
  .pipe(z.number().int().max(100));
 
const result = CreateArticleSchema.safeParse({
  title: "   ",
  body: "An article body",
  categoryId: "backend",
});
 
if (!result.success) {
  console.log(result.error.issues.map((issue) => ({
    field: issue.path.join("."),
    message: issue.message,
  })));
}

A whitespace-only title fails after trimming. Extra fields such as authorId or role also fail. After successful validation, use result.data; do not spread the original req.body into a database operation.

LimitSchema checks the string before converting it. It does not silently turn an empty string into zero or accept 20abc. Missing input is represented by undefined; if using URLSearchParams.get(), convert its missing-value null to undefined. Only the title is trimmed here: whitespace in an article body may be intentional content.

3.3 Omitted values, null, and empty strings differ

A partial update contract must define these cases:

Input Convention in this example
No title field Preserve the current title
"title": null Reject: titles cannot be null
"title": "" or whitespace only Reject: a title needs content
"title": "New title" Replace the title
{} Reject a request with no changes

.partial() allows omission; it does not define how to clear a field. An optional summary could separately allow null to mean removal. Clients should not have to guess whether missing data means preserve, clear, or reject.

3.4 Schemas, business rules, and database constraints have distinct jobs

A schema can establish that categoryId is a string. It cannot establish that the category exists or that this author can publish there. The application service enforces those rules, while the database enforces final constraints such as foreign keys and uniqueness.

Suppose two requests both check that a slug is available and then create an article. Both preliminary checks can succeed. A database uniqueness constraint is still necessary, with its failure mapped to a stable business error. A read-before-write check is not concurrency control.

Validation also does not replace every other boundary: SQL should be parameterized, user HTML needs treatment appropriate to its rendering context, and responses should expose only approved fields. A generic “remove dangerous characters” function cannot solve all of those problems.

3.5 Give clients actionable field errors

This article uses 400 for malformed JSON and 422 for parseable input that violates field rules. A team can consistently choose 400 for validation instead. What matters is a stable structure and a distinction between fixable input and server failure.

The following response uses the Problem Details structure. errors and requestId are application-defined extensions. Clients rely on stable codes, while user-facing messages can be localized. See RFC 9457.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json
 
{
  "type": "https://api.example.com/problems/validation",
  "title": "Invalid article input",
  "status": 422,
  "errors": [
    { "field": "title", "code": "REQUIRED", "message": "Title is required" }
  ],
  "requestId": "req_01"
}

Internal logs may retain detailed exceptions, but responses should not expose SQL, secrets, or stack traces. A request ID lets a client report correlate with server-side records.

4. REST: design resources and their HTTP contracts

4.1 Model business resources, not database tables

REST also involves constraints such as stateless interaction, caching, a uniform interface, and layering. Nouns in URLs alone do not establish REST conformance. The examples below concern common resource-oriented HTTP APIs. Stateless interaction requires requests to carry the necessary context; it does not prohibit storing resource data such as articles.

GET    /api/articles              List visible articles
POST   /api/articles              Create an article
GET    /api/articles/123          Read one article
PATCH  /api/articles/123          Partially update an article
DELETE /api/articles/123          Delete an article
POST   /api/articles/123/publications   Start publication

A publication resource is useful when the system tracks a publishing time, reviewer, or job. A simple status transition may instead fit a clearly defined update operation. Either way, URL style must not obscure business rules: an ordinary PATCH must not let an author bypass review by setting a published flag.

List queries should scope data to what the caller can see before pagination and counting. Fetching 20 rows and then removing unauthorized articles produces inconsistent page sizes and may leak hidden-resource counts.

4.2 Method semantics, safety, and idempotency

A safe method does not request a business-state change. Idempotency means repeating an identical request has the same intended effect as executing it once; identical response codes and bodies are not required. See the MDN method reference.

Method Use in this example Repetition consideration
GET Read articles Reading must not trigger publication or deletion
POST Create an article May create duplicates without extra protection
PUT Replace the target's writable representation Must preserve idempotent semantics
PATCH Apply an agreed partial modification Idempotency depends on the operation
DELETE Remove a particular article A later 404 can still be idempotent

PUT is not a generic synonym for “update whichever fields were supplied.” If offered, its contract should define the complete writable representation and omitted-field behavior. Server-generated fields such as IDs follow the documented contract.

A PATCH format must be explicit. Setting a title to a fixed value can be idempotent; incrementing a counter is not. This article uses a custom JSON update object, not JSON Patch or JSON Merge Patch. Adopting either standard requires its media type and semantics. See RFC 5789.

4.3 Status codes should help the caller decide what to do next

These are conventions for the article system, not the only possible API contract:

Status Situation here Client action
200 Read or update succeeds with a representation Update the interface
201 Article created Use the returned ID and Location
204 Deleted with no response body Do not attempt to parse JSON
400 Malformed JSON or query format Correct the request
401 No valid credentials Sign in or refresh according to policy
403 Identified caller cannot edit Show a permission error; do not keep refreshing
404 Missing resource, or existence deliberately concealed Show unavailable content
409 Duplicate slug or business-state conflict Change input or confirm the operation again
412 If-Match condition fails Re-read and resolve the version conflict
422 Field validation fails Identify the relevant form fields
429 Call quota exceeded Back off using Retry-After or the documented policy
500 / 503 Unexpected failure / temporary unavailability Consider whether this operation is safe to retry

RFC 9110 defines HTTP methods, conditional requests, and core status semantics. A 401 needs an appropriate WWW-Authenticate challenge; a bearer API should not return only a JSON message saying that login is required.

HTTP/1.1 201 Created
Location: /api/articles/123
Content-Type: application/json
 
{
  "id": "123",
  "title": "Understanding API design",
  "authorId": "user_7",
  "version": 1
}

Errors also need stable machine-readable codes. SLUG_TAKEN and ARTICLE_ALREADY_PUBLISHED may both use 409 but require different recovery. Clients should not parse English sentences to decide which action to take.

4.4 Pagination, filters, and ordering form one contract

GET /api/articles?category=backend&sort=-createdAt&limit=20&cursor=<opaque_cursor>

Offset pagination is straightforward, but insertions and deletions can shift later pages. Cursor pagination can continue from ordering keys. This example orders by createdAt DESC, id DESC, using a unique ID to break timestamp ties.

A cursor must correspond to the same filtering and ordering conditions, and every request still needs authorization. Bound limit and allowlist sort fields rather than interpolating client input into SQL. Mutable ordering fields also require a clear consistency policy; a cursor does not automatically provide a snapshot of changing data.

{
  "items": [
    { "id": "123", "title": "Understanding API design" }
  ],
  "pageInfo": {
    "endCursor": "opaque_cursor",
    "hasNextPage": true
  }
}

4.5 Retry after a timeout without creating duplicate articles

A timeout means that the client did not receive a response in time. It does not establish that the database write failed. The article may already exist when the user clicks Retry.

A creation endpoint can define an Idempotency-Key contract. The header does not implement deduplication by itself:

POST /api/articles HTTP/1.1
Idempotency-Key: create-article-7f31
Content-Type: application/json

The server associates the key with the caller, operation, and a digest of the request content. Repeating the key with the same input reuses the existing result; different input under the same key is rejected. Document how duplicates behave while the first request is still running, such as waiting or receiving a conflict.

Coordinate the idempotency record with the business write using uniqueness constraints and transactions. Otherwise two concurrent requests can both conclude that the key is absent. For external effects such as notifications, record pending events in the transaction, deliver them asynchronously, and deduplicate at the consumer. Retention is finite, so this contract is not a promise of “exactly once forever.”

4.6 Concurrent editing without overwriting unseen changes

Alice and Bob both read version 7. Alice saves, producing version 8. If Bob saves unconditionally, he may overwrite changes he has never seen.

One approach returns a strong ETag when reading and requires If-Match when updating:

PATCH /api/articles/123 HTTP/1.1
If-Match: "article-123-v7"
Content-Type: application/json
 
{ "title": "A revised title" }

The ETag must track changes to the representation. A failed condition returns 412; the client should preserve local edits and offer reload or merge rather than silently obtain a new ETag and overwrite. See the MDN If-Match reference.

Compare the version and write atomically, rather than checking first and updating unconditionally later. This SQL shows the core mechanism; the service must also enforce ownership and tenant scope:

UPDATE articles
SET title = :title, version = version + 1
WHERE id = :id AND version = :expected_version;

Zero affected rows means the condition did not match. Interpret that according to resource state and the public error policy. If ownership or permissions can change concurrently, the transaction or conditional update must also keep those checks consistent with the write.

4.7 Caching and API evolution

Public articles can have cache policies and conditional reads to reduce transfers. Drafts, personal information, and user-specific representations need explicit private-cache or no-storage policies. Cache keys must not mix content belonging to different users. See the MDN caching guide.

Adding an optional field is often easier to make compatible than renaming or removing one, but strict clients may still reject additional fields. Verify consumer contracts instead of assuming all additive changes are harmless.

Before breaking a contract, provide a migration window, document deprecation, and observe remaining use of the old behavior. Whether versioning uses /v1, media types, or another convention, clients need to know when they must change.

5. GraphQL: clients select fields, servers control execution

5.1 A schema describes the queryable graph

An article page needs the title, author, and some comments. This schema describes reading and updating that data, omitting unrelated production fields:

type User {
  id: ID!
  name: String!
}
 
type Comment {
  id: ID!
  content: String!
}
 
type Article {
  id: ID!
  title: String!
  body: String!
  categoryId: ID!
  author: User!
  comments(first: Int! = 10): [Comment!]
  version: Int!
}
 
input UpdateArticleInput {
  title: String
  body: String
  categoryId: ID
}
 
type Query {
  article(id: ID!): Article
}
 
type Mutation {
  updateArticle(id: ID!, input: UpdateArticleInput!, expectedVersion: Int!): Article!
}

ID! requires a non-null ID value but does not check database existence. [Comment!] allows a null list but disallows null elements inside a returned list; [] means a successful result containing no comments. Nullable Query.article offers one way to represent the absence of a visible article. See GraphQL schemas and types.

Input types do not express every business constraint either. Accepting a string title does not validate its length. This example reuses the earlier schema to reject explicit nulls, blank titles, and empty update objects.

5.2 How queries, variables, and resolvers work together

query ArticlePage($id: ID!, $first: Int!) {
  article(id: $id) {
    id
    title
    author {
      name
    }
    comments(first: $first) {
      content
    }
  }
}
{
  "id": "123",
  "first": 5
}

The document selects a shape; variables supply input for this execution. The server parses and validates the operation before resolving fields. A resolver can obtain values from its parent object, a database, a cache, or another service. Fields do not necessarily map one-to-one to tables or SQL queries. See GraphQL execution.

An update uses a mutation. Here, expectedVersion serves the same business purpose as a conditional REST update, while the client selects the returned fields:

mutation EditArticle($id: ID!, $input: UpdateArticleInput!, $version: Int!) {
  updateArticle(id: $id, input: $input, expectedVersion: $version) {
    id
    title
    version
  }
}
{
  "id": "123",
  "input": { "title": "A revised title" },
  "version": 7
}

This resolver sketch illustrates the calls. A request-level authentication layer builds Context; articleService enforces visibility, edit permissions, business validation, and concurrent version checks:

const resolvers = {
  Query: {
    article: (_parent: unknown, args: { id: string }, ctx: Context) =>
      ctx.articleService.findVisibleById(ctx.actor, args.id),
  },
  Article: {
    author: (article: ArticleRecord, _args: unknown, ctx: Context) =>
      ctx.loaders.userById.load(article.authorId),
    comments: (
      article: ArticleRecord,
      args: { first: number },
      ctx: Context,
    ) => {
      if (!Number.isInteger(args.first) || args.first < 1 || args.first > 50) {
        throw new GraphQLError("first must be between 1 and 50", {
          extensions: { code: "BAD_USER_INPUT" },
        });
      }
      return ctx.commentService.listVisible(ctx.actor, article.id, args.first);
    },
  },
  Mutation: {
    updateArticle: async (
      _parent: unknown,
      args: { id: string; input: unknown; expectedVersion: number },
      ctx: Context,
    ) => {
      const actor = requireActor(ctx);
      const input = UpdateArticleSchema.safeParse(args.input);
      if (!input.success) {
        throw new GraphQLError("Invalid article input", {
          extensions: { code: "BAD_USER_INPUT" },
        });
      }
      return ctx.articleService.update({
        actor,
        id: args.id,
        changes: input.data,
        expectedVersion: args.expectedVersion,
      });
    },
  },
};

This assumes GraphQLError is imported from graphql and that the application supplies the types and services. Service-level permission and conflict errors still need mapping to the client's documented errors, while internal exceptions remain private.

Checking that someone is signed in at /graphql does not authorize every field. Keep resource policies in the business layer and apply field policies to sensitive data such as email addresses. REST, GraphQL, and background jobs should not maintain conflicting versions of the same rules. See GraphQL authorization guidance.

5.3 Partial data and errors can coexist

Suppose the title and author load, but the comment service fails. Because the comments list above is nullable, the result can be:

{
  "data": {
    "article": {
      "id": "123",
      "title": "Understanding API design",
      "author": { "name": "Alice" },
      "comments": null
    }
  },
  "errors": [
    {
      "message": "Comments are temporarily unavailable",
      "path": ["article", "comments"],
      "extensions": { "code": "UPSTREAM_UNAVAILABLE" }
    }
  ]
}

The client can render the article and handle the comments failure separately. A failed non-null field propagates null to an enclosing nullable position, so ! has a recovery consequence. Parsing or validation failures before execution do not produce successful execution data. See GraphQL responses.

“GraphQL always returns 200” is not a reliable rule. HTTP behavior depends on transport, media type, and the stage of failure. Clients must handle network and HTTP failures as well as data and errors. The extensions.code values above are application conventions, not universal GraphQL error codes.

5.4 N+1 queries: fewer selected fields do not guarantee less work

Imagine a list operation loads 20 articles, then each article's author resolver performs a separate query. That can produce one article query plus 20 author queries, including repeated reads when several articles share an author.

DataLoader can batch loads and cache repeated keys within a request. Its batch function must return results aligned with the input keys and handle missing records. Create loaders per request rather than sharing permission-dependent results across users. See the official DataLoader documentation.

This is not an automatic performance cure. Nested comments, differing filters, and remote calls still need analysis. Measure query counts and duration before choosing batching, joins, prefetching, or caching. A client selecting only title does not guarantee that a resolver avoids loading a complete row internally.

5.5 Demand control, caching, and subscriptions

GraphQL flexibility needs execution budgets: bound list sizes, query depth and breadth, estimate field cost, set timeouts, and enforce caller quotas. Depth limits alone do not stop many aliases at the same level. HTTP request counts alone do not measure database work. See GraphQL security guidance.

Controlled clients can use trusted operation lists or persisted operations, but variables and permissions still need validation. Cache policies must include variables, identity, and field visibility. GraphQL is cacheable; a single shared URL simply cannot represent every result safely.

Subscriptions deliver an ongoing stream of events, without inherently choosing one transport protocol. Real implementations need reconnection behavior, credential expiry handling, and event-level visibility. An article editor should take on that complexity when it actually needs live collaboration.

6. Choose REST or GraphQL for the same requirement

An article page needs an article, its author, and comments. REST can return an aggregate representation or expose separate resources. GraphQL can let the client select the combination. Do not start the comparison by assuming REST must make three requests and GraphQL must make only one.

Dimension REST design focus GraphQL design focus
Client requirements Stable resource contracts and aggregation where useful Different field selections across clients
Performance Request count, representation size, queries, and caching Field execution, batching, and complexity budgets
Errors Status codes and stable problem structures Transport errors, field errors, and partial data
Permissions Routes, resources, and response fields Operations, resources, fields, and relationship paths
Evolution Compatibility, versions, and deprecation windows Schema deprecation, consumer usage, and migration
Operations Duration and errors by route Operation and field cost, not only /graphql metrics

For this system, one management interface and a stable set of pages would lead me to evaluate whether a resource-oriented API meets the requirements first. If web, mobile, and partner clients repeatedly need substantially different data combinations, I would then assess whether GraphQL's flexibility justifies its execution and maintenance costs.

Both interfaces can share an application service. Changing interface style does not fix missing authorization, slow database queries, or inconsistent business rules.

7. Walk through a complete article update

Alice edits her own article. The REST request includes credentials, updated fields, and If-Match. A GraphQL mutation supplies variables and expectedVersion. Both eventually call the same business service.

Identify the caller
  → Parse and validate input
  → Load the article and check permission and version consistently
  → Update only permitted fields
  → Commit atomically and increment the version
  → Return the approved public representation
Situation Server behavior Client recovery
Author saves successfully Return the new article and version Update local state
Credentials are invalid Reject the unauthenticated operation Refresh once if applicable or sign in
Caller edits another author's article Reject without leaking sensitive content Explain the permission failure
Title contains only spaces Return a field error Preserve the draft and identify the title
Another edit changed the version Reject the stale write Compare versions or merge manually
Database transaction fails Roll back and log the internal failure Keep input and respond to the error category
Write succeeds but response is lost Do not equate a timeout with rollback Re-read state; protect creates with idempotency keys

The frontend should not discard user input after a network failure. The backend should not turn every exception into 500, leaving the client unable to distinguish “fix the title” from “try again later.”

Logs should correlate request IDs, operations, outcomes, and duration. Audit records can establish who changed an article. Avoid complete tokens, passwords, and unnecessary sensitive content in logs. For GraphQL, track operations and cost as well as the endpoint; /graphql success rates alone are not enough.

8. Practical checks and self-assessment

Turn these scenarios into interface-level tests. Assert the database effects as well as the response:

  • Missing credentials are rejected without creating an article.
  • Alice cannot edit Bob's article by changing an ID.
  • Disallowed fields such as authorId are rejected, and authorship comes from the verified identity.
  • Omitted values, explicit nulls, blank titles, oversized bodies, and invalid pagination inputs receive defined treatment.
  • Concurrent creates using one idempotency key produce one business result; different input under that key is rejected.
  • Competing edits with the same old version follow the concurrency policy rather than both overwriting successfully.
  • A comment failure preserves or propagates GraphQL data according to schema nullability.
  • Permission-dependent DataLoader caches are not reused across users.
  • Expensive operations are constrained, and errors do not expose credentials or stack traces.

Answer these questions, then check your reasoning against the examples:

  1. Why are sessions and JWTs not opposites of cookies? They describe state or token design, while cookies are a carrying mechanism. Compare them at the right layer.
  2. Why can a correctly signed JWT still fail API access? Audience, lifetime, revocation policy, and resource permissions still matter.
  3. Why validate business input when GraphQL already has types? A string type does not establish title length, ownership, or a permitted state transition.
  4. Does a second DELETE returning 404 break idempotency? Not necessarily. Compare intended effects rather than responses alone.
  5. Why is a uniqueness pre-check insufficient? Concurrent callers can pass it together; the final constraint needs atomic enforcement.
  6. Why does reading an ETag not eliminate the need for a conditional write? State can change between reading and writing, so the version check must coordinate with the update.
  7. Why can fewer GraphQL requests make a backend slower? One request may trigger many resolvers and database calls; measure execution cost.

The practical goal is to turn a requirement into a request, a success response, and failure responses, then explain where permissions are enforced, how writes avoid duplication or lost updates, and what the caller should do next.