Mutations, input types, validation
Write শুধু side effect সহ query নয়। এদের input type, validation, transaction, idempotency, আর এমন একটা return shape লাগে যা client-কে দ্বিতীয়বার fetch না করেই তার cache update করতে দেয়।
একটা Mutation field আসলে একটা resolver যা write করে। schema, executor, resolver signature — সব query-র মতোই। যা বদলায় সেটা হলো resolver-এর চারপাশের সবকিছু: input shape, validation, transactional scope, error reporting, আর তুমি কী ফেরত দাও যাতে client state refresh করতে পারে।
বাস্তব জীবনের উপমা
একটা mutation হলো একটা form submission-এর মতো, বনাম একটা read-only search — mutation state বদলায়, query তা observe করে।
গল্পে বুঝি
পুরনো শহরের রেকর্ড অফিসে সিনা তার ঠিকানা বদলাতে এসেছেন। বড় হলঘরের বাঁদিকের জানালাটা শুধু পড়ার জন্য — ওখানে গিয়ে কারো রেকর্ড দেখা যায়, কিন্তু কিছু বদলানো যায় না। ঠিকানা বদলাতে হলে যেতে হবে ডানদিকের “পরিবর্তন” জানালায়। সেখানে কেরানি খোয়ারিজমি একটা নির্দিষ্ট ছাপানো ফর্ম এগিয়ে দিলেন — তাতে ঠিক যে ঘরগুলো লাগবে সেগুলোই আছে: পুরনো ঠিকানা, নতুন ঠিকানা, নাগরিক নম্বর। যেমন খুশি কাগজে লিখে আনলে চলবে না, এই ফর্মের ছকেই ভরতে হবে।
সিনা ফর্ম ভরে জমা দিতেই খোয়ারিজমি সঙ্গে সঙ্গে লেজারে হাত দিলেন না। আগে তিনি প্রতিটা ঘর মিলিয়ে দেখলেন — নাগরিক নম্বরটা কি আসল, নতুন ঠিকানাটা কি ফাঁকা, বানানে কি গোলমাল আছে। ফাতিমা একবার নাগরিক নম্বরের ঘর ফাঁকা রেখে ফর্ম দিয়েছিলেন — কেরানি লেজার একটুও না ছুঁয়ে ফর্মটা সোজা ফেরত দিয়েছিলেন। সব ঠিক থাকলে তবেই খোয়ারিজমি মূল লেজারে নতুন ঠিকানা তুলে দেন, আর সিনার হাতে ধরিয়ে দেন হালনাগাদ রেকর্ডের একটা টাটকা কপি — যেখানে বদলটা কালো অক্ষরে দেখা যাচ্ছে।
এই “পরিবর্তন” জানালাটাই একটা mutation — data বদলানোর কাজ এখানেই হয়, পড়ার জানালা (query) আলাদা। ছাপানো নির্দিষ্ট ফর্মটা হলো input type: কোন ঘরগুলো লাগবে তার নির্দিষ্ট shape। কেরানির প্রতিটা ঘর মিলিয়ে দেখা আর ভুল ফর্ম ফেরত দেওয়াটা validation — লেজার ছোঁয়ার আগেই খারাপ input আটকানো। মূল লেজারে ঠিকানা তোলা হলো আসল write, আর হাতে ধরানো টাটকা কপিটাই mutation-এর return — client দ্বিতীয়বার query না করেই তার state update করতে পারে। বাস্তবে GraphQL resolver-ও ঠিক এভাবেই কাজ করে: input type-এ shape ঠিক করো, boundary-তে validate করো, তারপর write করে বদলে যাওয়া entity ফেরত দাও।
Mutation বনাম query — আসল পার্থক্য
GraphQL spec একটা জিনিসের নিশ্চয়তা দেয়: যখন একটা single request-এ একাধিক top-level mutation থাকে, তারা sequentially, order অনুযায়ী চলে। query গুলো parallel-এ fan out করে।
mutation {
createPost(input: { title: "A" }) {
id
}
createPost(input: { title: "B" }) {
id
}
createPost(input: { title: "C" }) {
id
}
} B শুরু হওয়ার আগে A পুরোপুরি resolve হয়। এটাই একমাত্র execution-level পার্থক্য। বাকি সব convention — convention অনুযায়ী, mutation হলো সেই জায়গা যেখানে তুমি write রাখো; engine-এর কিছুই এটা বাধ্য করে না।
বাস্তবে, এক request-এ অনেক mutation batch করো না। প্রতি write-এ একটা mutation করো। Network round trip সস্তা; concurrency-control bug সস্তা নয়।
Input type — verbose কিন্তু মূল্যবান
Mutation-এর একটা single input: <Verb><Noun>Input! argument নেওয়া উচিত, flat scalar-এর একটা list নয়।
# Don't do this
type Mutation {
createPost(title: String!, body: String!, tags: [String!], publish: Boolean): Post!
}
# Do this
input CreatePostInput {
title: String!
body: String!
tags: [String!]
publish: Boolean = false
}
type Mutation {
createPost(input: CreatePostInput!): Post!
} কেন এই কষ্ট:
- Field যোগ করা সস্তা।
CreatePostInput-এexcerpt: Stringযোগ করো আর client যখন চায় তখন পাঠায়। flat arg দিয়ে তুমি একটা positional argument যোগ করতে আর default mismatch-এর risk নিতে। - Input typed। Default value, validation directive, description — সব input type definition-এ থাকে। Tooling এটা পড়ে।
- Reusable।
UpdatePostInput,CreatePostInput-এর pattern extend করতে পারে। Variant গুলো disciplined থাকে। - Client-এ পড়তে সুবিধা। যে form code field গুলো একটা object-এ জড়ো করে, তারপর submit করে, সেটা arg ছড়িয়ে দেওয়ার চেয়ে বেশি স্বাভাবিক।
সর্বজনীনভাবে apply করো। এমনকি সবচেয়ে সহজ one-field mutation-ও নিজের input পায়।
একটা বাস্তব mutation, end-to-end
Schema:
input CreatePostInput {
title: String!
body: String!
tags: [String!]
publish: Boolean = false
}
type Mutation {
createPost(input: CreatePostInput!): Post!
updatePost(id: ID!, input: UpdatePostInput!): Post!
deletePost(id: ID!): Boolean!
}
input UpdatePostInput {
title: String
body: String
tags: [String!]
} Resolver, transaction আর validation সহ:
import { z } from 'zod';
const CreatePostSchema = z.object({
title: z.string().min(1).max(200),
body: z.string().min(1),
tags: z
.array(z.string().regex(/^[a-z][a-z0-9-]{0,30}$/))
.max(10)
.optional(),
publish: z.boolean().optional()
});
const Mutation = {
createPost: async (_, { input }, ctx) => {
if (!ctx.userId) throw new Error('Not authenticated');
const data = CreatePostSchema.parse(input);
const client = await ctx.db.connect();
try {
await client.query('BEGIN');
const { rows } = await client.query(
`INSERT INTO posts (author_id, title, body, published)
VALUES ($1, $2, $3, $4)
RETURNING *`,
[ctx.userId, data.title, data.body, !!data.publish]
);
const post = rows[0];
if (data.tags?.length) {
await client.query(
`INSERT INTO post_tags (post_id, tag)
SELECT $1, unnest($2::text[])
ON CONFLICT DO NOTHING`,
[post.id, data.tags]
);
}
await client.query('COMMIT');
return post;
} catch (e) {
await client.query('ROLLBACK');
throw e;
} finally {
client.release();
}
}
}; কয়েকটা pattern উল্লেখ করার মতো।
Boundary-তে validate করো। input-এ Zod (বা Yup, বা ajv) ব্যবহার করো। Business rule-এর জন্য GraphQL-এর type system-এ ভরসা করো না — এটা তোমাকে “একটা string কিনা” দেয়, তোমার আরও লাগে “1–200 char, শুধু whitespace নয়, কোনো HTML নেই।” Validation তোমার কাজ।
Multi-statement write-এর জন্য transaction optional নয়। pool থেকে একটা client টেনে নাও, BEGIN, সব করো, COMMIT বা ROLLBACK। এটা ছাড়া, step 2-এর পরে একটা error step 1-কে committed রেখে দেয়। চিরকালের inconsistent state।
Client acquire করো, rollback path-এর জন্য pool.query ব্যবহার করো না। pool.query প্রতিটা call-এ একটা connection checkout করে। atomicity-র জন্য তোমার পুরো transaction জুড়ে একটা connection লাগে।
Validation-এর কৌশল
Validate করার তিনটা জায়গা:
- Schema (free)। GraphQL type, nullability, enum membership enforce করে।
Boolean!কখনো"true"হতে পারে না — তোমার resolver চলার আগে executor reject করে। - Input shape (Zod)। Length, regex, range, conditional logic। ব্যবহারযোগ্য একটা error দিয়ে reject করে।
- Resolver-এ business rule। “একটা deleted org-এ post publish করা যায় না।” Zod parse-এর পরে যায়, যেখানে তোমার typed input আছে।
Zod-এ business rule রেখো না। Resolver-এ length check রেখো না। যেখানে যেটার জায়গা সেখানে layer করো।
যেসব error-এ client কাজ করতে পারে
একটা খালি throw new Error("not found") errors[]-এ কোনো structure ছাড়া একটা generic message হয়ে যায়। Client “validation failed”-কে “internal server error” থেকে বা “unauthorized” থেকে আলাদা করতে পারে না।
দুটো option।
1. code দিয়ে typed error। graphql-yoga extension সহ GraphQLError support করে:
import { GraphQLError } from 'graphql';
throw new GraphQLError('Invalid title', {
extensions: { code: 'VALIDATION_FAILED', field: 'title' }
}); Client err.extensions.code পড়ে আর তার উপর branch করে।
2. data হিসেবে error (union pattern)। chapter 2 থেকে:
union CreatePostResult = Post | ValidationError | NotAuthorizedError
type Mutation {
createPost(input: CreatePostInput!): CreatePostResult!
} Verbose কিন্তু watertight। Client প্রতিটা case-এর জন্য fragment লেখে আর schema প্রতিটা failure mode document করে।
ছোট graph-এর জন্য extension দিয়ে code ঠিক আছে। অনেক team যে contract consume করে, তার জন্য errors-as-data মূল্যবান।
Internal জিনিস লুকাও। একটা Postgres unique-violation error-এ constraint name, table name, কখনো offending value থাকে। এটা pass through করো না। resolver-এ catch করো, একটা clean error-এ map করো: throw new GraphQLError("Email already in use", { extensions: { code: "DUPLICATE", field: "email" } })। Stack trace আর SQL তোমার log-এ থাকে, client response-এ নয়।
Idempotency
Mutation mid-flight fail করতে পারে — network drop, client retry। Idempotency ছাড়া, user “create post”-এ দুবার click করে আর দুটো post নিয়ে শেষ হয়।
সবচেয়ে পরিষ্কার সমাধান: client একটা clientMutationId (UUID) পাঠায় আর server এটা store করে।
input CreatePostInput {
clientMutationId: ID!
title: String!
body: String!
} CREATE TABLE mutation_idempotency (
client_id UUID PRIMARY KEY,
result JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
); resolver-এ, প্রথমে table check করো; যদি একটা row থাকে, তার stored result ফেরত দাও। যদি না থাকে, কাজটা করো, result store করো, ফেরত দাও।
কম গুরুত্বপূর্ণ write-এর জন্য (draft, comment) এটা অতিরিক্ত। Payment, account creation, যা কিছু customer-রা care করে — তার জন্য non-negotiable।
Client-এর update করার মতো যথেষ্ট ফেরত দেওয়া
যদি createPost Boolean ফেরত দেয়, client-কে UI update করতে users আর posts refetch করতে হয়। অপচয়।
নতুন entity ফেরত দাও, client-এর cache-এ splice করার মতো যথেষ্ট field সহ:
type Mutation {
createPost(input: CreatePostInput!): Post!
} Apollo Client আর urql ফেরত দেওয়া Post পড়বে, তাদের normalized cache-এ id দিয়ে খুঁজে বের করবে, আর তুমি যদি সেগুলোও include করো তবে সংশ্লিষ্ট list গুলো update করবে।
যেসব mutation list-এ প্রভাব ফেলে, তাদের জন্য parent-ও ফেরত দাও:
type CreatePostPayload {
post: Post!
author: User! # has updated post count, latest activity, etc.
} এখন একটা mutation request client-এর দরকারি সবকিছু ফেরত দেয়। শূন্য refetch।
Bulk mutation
50টা post তৈরি করা দরকার? createPostBatch(inputs: [CreatePostInput!]!) expose করো না। Sequential top-level mutation কাজ করে, কিন্তু network-এর উপর সেগুলো ধীর।
আরও ভালো: একটা single mutation যা array নেয়, এক transaction-এ চলে:
type Mutation {
importPosts(inputs: [CreatePostInput!]!): ImportPostsPayload!
}
type ImportPostsPayload {
created: [Post!]!
failures: [ImportFailure!]!
}
type ImportFailure {
index: Int!
reason: String!
} এক round trip, এক transaction, partial-failure reporting। এখানেই union-typed error pattern তার মূল্য প্রমাণ করে — প্রতিটা item আলাদাভাবে succeed বা fail করতে পারে।
Mutation আর DataLoader cache
user 42-তে write করা একটা mutation যেকোনো concurrent বা following request-এর loader cache invalidate করে যা user 42 load করেছিল। যেহেতু loader per-request আর short-lived, একটা single request-এর ভেতরে এটা খুব কমই সমস্যা — কিন্তু যদি তোমার resolver mutate করে তারপর একই mutation-এ read করে, write-এর পরে loader prime বা clear করো:
ctx.loaders.user.clear(post.author_id);
// or
ctx.loaders.user.prime(String(updatedUser.id), updatedUser); নয়তো post-mutation read stale, pre-mutation row ফেরত দেয়।
রিক্যাপ
- Batch করা হলে mutation sequentially চলে। query থেকে এটাই একমাত্র engine-level পার্থক্য।
- সবসময়
input <Verb><Noun>Input!type ব্যবহার করো। এক arg, কখনো flat scalar নয়। - Zod দিয়ে boundary-তে validate করো। Schema type দেয়, তুমি rule দাও।
- Multi-statement write একটা connection সহ একটা real transaction-এ wrap করো।
- ছোট graph-এর জন্য
extensionsদিয়ে error code; বড় graph-এর জন্য errors-as-data union। - Internal error লুকাও। DB constraint name-কে user-readable message-এ map করো।
- গুরুত্বপূর্ণ যেকোনো কিছুর জন্য
clientMutationId। Idempotency একটা feature, শখ নয়। - বদলে যাওয়া entity ফেরত দাও, client-এর cache update করার মতো যথেষ্ট scope সহ।
পরবর্তী: Authentication and authorization — context, field-level check, আর directives pattern।