Chapter 22: GraphQL and SOAP#
1. The Problem GraphQL Solves#
Your mobile app needs a list of products. Each product carries a name, price, 20 image URLs, a full description, 15 review objects, and 8 fields you do not need. REST sends all of it -- 50KB of JSON when you needed 2KB. On a mobile connection, that waste stings.
Now your web dashboard needs the same products plus category, stock status, and supplier info. REST forces you to make three requests and stitch them together, or build a custom endpoint with query parameter parsing.
GraphQL ends both problems. The client asks for the fields it needs. The server returns those fields. One endpoint. One request. One response shaped to fit.
Tina4 includes a built-in GraphQL engine. No external packages. No Strawberry. No Ariadne. Part of the framework.
2. GraphQL vs REST -- A Quick Comparison#
| Aspect | REST | GraphQL |
|---|---|---|
| Endpoints | One per resource (/products, /users) | One endpoint (/graphql) |
| Data shape | Server decides | Client decides |
| Over-fetching | Common (get all fields) | Never (get only requested fields) |
| Under-fetching | Common (multiple requests needed) | Never (get related data in one query) |
| Versioning | /api/v1/, /api/v2/ | Schema evolves, no versioning needed |
| Caching | HTTP caching works naturally | Requires client-side cache management |
| Learning curve | Low | Moderate |
REST is still great for simple APIs. GraphQL shines when clients have diverse data needs -- mobile apps, dashboards, third-party integrations.
3. Enabling GraphQL#
GraphQL is available by default in Tina4. The engine serves requests at /graphql. If you want to change the endpoint, set it in .env:
TINA4_GRAPHQL_ENDPOINT=/graphqlTo verify it is working, start the server and send a test query:
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ __schema { queryType { name } } }"}'{ "data": { "__schema": { "queryType": { "name": "Query" } } }}If you see that response, GraphQL is running.
4. Defining a Schema#
A GraphQL schema defines the types of data your API can return and the queries and mutations clients can execute.
Create src/graphql/schema.graphql:
type Product { id: Int! name: String! category: String! price: Float! inStock: Boolean! createdAt: String}โtype Query { products: [Product!]! product(id: Int!): Product}This schema says:
- A
Producthas six fields. The!means the field is non-nullable. - The
Querytype has two operations:productsreturns a list of products, andproduct(id)returns a single product (or null if not found).
Tina4 auto-discovers .graphql files in src/graphql/. You do not need to register them.
5. Writing Resolvers#
A resolver is the function that runs when a query or mutation is executed. Resolvers live in src/graphql/ as Python files.
Create src/graphql/resolvers.py:
from tina4_python.graphql import GraphQLโ# Resolve the "products" query@GraphQL.resolve("Query", "products")async def resolve_products(root, args): products = Product.where("1=1") return [p.to_dict() for p in products]โโ# Resolve the "product" query@GraphQL.resolve("Query", "product")async def resolve_product(root, args): product = Product.find(args["id"])โ if product is None: return Noneโ return product.to_dict()The @GraphQL.resolve() decorator takes two arguments:
- Type name -- The GraphQL type this resolver belongs to (
Query,Mutation, or a custom type). - Field name -- The field within the type this resolver handles.
The resolver function receives root (the parent value, if any) and args (the query arguments).
Testing the Queries#
List all products:
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ products { id name price inStock } }"}'{ "data": { "products": [ {"id": 1, "name": "Wireless Keyboard", "price": 79.99, "inStock": true}, {"id": 2, "name": "USB-C Hub", "price": 49.99, "inStock": true}, {"id": 3, "name": "Standing Desk", "price": 549.99, "inStock": false} ] }}Notice: the response only contains the four fields we asked for (id, name, price, inStock), not category or createdAt. That is GraphQL in action.
Get a single product with all fields:
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ product(id: 1) { id name category price inStock createdAt } }"}'{ "data": { "product": { "id": 1, "name": "Wireless Keyboard", "category": "Electronics", "price": 79.99, "inStock": true, "createdAt": "2026-03-22 14:30:00" } }}Request a product that does not exist:
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ product(id: 999) { id name } }"}'{ "data": { "product": null }}6. Mutations#
Mutations are how clients create, update, or delete data. They are defined in the schema alongside queries.
Update src/graphql/schema.graphql:
type Product { id: Int! name: String! category: String! price: Float! inStock: Boolean! createdAt: String}โinput ProductInput { name: String! category: String price: Float! inStock: Boolean}โinput ProductUpdateInput { name: String category: String price: Float inStock: Boolean}โtype DeleteResult { success: Boolean! message: String!}โtype Query { products: [Product!]! product(id: Int!): Product productsByCategory(category: String!): [Product!]!}โtype Mutation { createProduct(input: ProductInput!): Product! updateProduct(id: Int!, input: ProductUpdateInput!): Product deleteProduct(id: Int!): DeleteResult!}Key concepts:
inputtypes define the shape of data the client sends. They are like regular types but used for arguments.Mutationtype lists all write operations. By convention, mutations are named with verbs:createProduct,updateProduct,deleteProduct.- Fields in
ProductUpdateInputare all optional (no!) because the client may only want to update one field.
Mutation Resolvers#
Add to src/graphql/resolvers.py:
@GraphQL.resolve("Mutation", "createProduct")async def resolve_create_product(root, args): input_data = args["input"]โ product = Product() product.name = input_data["name"] product.category = input_data.get("category", "Uncategorized") product.price = float(input_data["price"]) product.in_stock = bool(input_data.get("inStock", True)) product.save()โ return product.to_dict()โโ@GraphQL.resolve("Mutation", "updateProduct")async def resolve_update_product(root, args): product = Product.find(args["id"])โ if product is None: return Noneโ input_data = args["input"] if "name" in input_data: product.name = input_data["name"] if "category" in input_data: product.category = input_data["category"] if "price" in input_data: product.price = float(input_data["price"]) if "inStock" in input_data: product.in_stock = bool(input_data["inStock"]) product.save()โ return product.to_dict()โโ@GraphQL.resolve("Mutation", "deleteProduct")async def resolve_delete_product(root, args): product = Product.find(args["id"])โ if product is None: return {"success": False, "message": "Product not found"}โ product.delete() return {"success": True, "message": "Product deleted"}โโ@GraphQL.resolve("Query", "productsByCategory")async def resolve_products_by_category(root, args): products = Product.where("category = ?", [args["category"]]) return [p.to_dict() for p in products]Testing Mutations#
Create a product:
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation { createProduct(input: { name: \"Desk Lamp\", category: \"Office\", price: 39.99 }) { id name price } }" }'{ "data": { "createProduct": { "id": 4, "name": "Desk Lamp", "price": 39.99 } }}Update a product:
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation { updateProduct(id: 4, input: { price: 44.99, inStock: false }) { id name price inStock } }" }'{ "data": { "updateProduct": { "id": 4, "name": "Desk Lamp", "price": 44.99, "inStock": false } }}Delete a product:
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "mutation { deleteProduct(id: 4) { success message } }" }'{ "data": { "deleteProduct": { "success": true, "message": "Product deleted" } }}7. Nested Types and Relationships#
The real power of GraphQL: traversing relationships in a single query. Authors, posts, comments -- all in one request.
Update src/graphql/schema.graphql:
type User { id: Int! name: String! email: String! posts: [Post!]!}โtype Post { id: Int! title: String! body: String! published: Boolean! author: User! comments: [Comment!]! commentCount: Int!}โtype Comment { id: Int! authorName: String! body: String! post: Post!}โtype Query { posts: [Post!]! post(id: Int!): Post user(id: Int!): User}โtype Mutation { createPost(userId: Int!, title: String!, body: String!, published: Boolean): Post! addComment(postId: Int!, authorName: String!, body: String!): Comment!}Add resolvers for the nested types:
@GraphQL.resolve("Query", "posts")async def resolve_posts(root, args): posts, count = Post.where("published = ?", [1]) return [p.to_dict() for p in posts]โโ@GraphQL.resolve("Query", "post")async def resolve_post(root, args): post = Post.find(args["id"]) return post.to_dict() if post else Noneโโ# Resolve Post.author (nested field)@GraphQL.resolve("Post", "author")async def resolve_post_author(post, args): user = User.find(post["user_id"]) return user.to_dict() if user else Noneโโ# Resolve Post.comments (nested field)@GraphQL.resolve("Post", "comments")async def resolve_post_comments(post, args): comments, count = Comment.where("post_id = ?", [post["id"]]) return [c.to_dict() for c in comments]โโ# Resolve Post.commentCount (computed field)@GraphQL.resolve("Post", "commentCount")async def resolve_post_comment_count(post, args): comments, count = Comment.where("post_id = ?", [post["id"]]) return countโโ# Resolve User.posts (nested field)@GraphQL.resolve("User", "posts")async def resolve_user_posts(user, args): posts, count = Post.where("user_id = ?", [user["id"]]) return [p.to_dict() for p in posts]Now clients can query across relationships in a single request:
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "{ posts { id title author { name email } comments { authorName body } commentCount } }" }'{ "data": { "posts": [ { "id": 1, "title": "My First Post", "author": { "name": "Alice", "email": "alice@example.com" }, "comments": [ {"authorName": "Bob", "body": "Great post!"} ], "commentCount": 1 } ] }}One request. Exactly the fields the client needs. No over-fetching. No under-fetching.
8. Auto-Generating Schema from ORM Models#
If your ORM models have auto_crud = True, Tina4 can auto-generate GraphQL types and basic CRUD resolvers for them. Enable this in .env:
TINA4_GRAPHQL_AUTO_SCHEMA=trueWith this setting, every ORM model with auto_crud = True automatically gets:
- A GraphQL type with fields matching the model properties
- A query to list all records (e.g.,
products) - A query to get one by ID (e.g.,
product(id: Int!)) - A mutation to create (e.g.,
createProduct(input: ProductInput!)) - A mutation to update (e.g.,
updateProduct(id: Int!, input: ProductUpdateInput!)) - A mutation to delete (e.g.,
deleteProduct(id: Int!))
You can still define custom resolvers that override the auto-generated ones. Custom resolvers always take precedence.
9. The GraphiQL Playground#
When TINA4_DEBUG=true, Tina4 serves a GraphiQL interactive playground at:
http://localhost:7146/graphql/playgroundGraphiQL gives you:
- A query editor with syntax highlighting and auto-completion
- A documentation explorer showing all types, queries, and mutations
- A results panel showing the response
- Query history
This is the fastest way to explore and test your GraphQL API during development. Type a query on the left, click the play button, and see the results on the right. The documentation explorer on the right sidebar shows every type, field, and argument available in your schema.
GraphiQL is only available when TINA4_DEBUG=true. In production, it is disabled automatically.
10. Query Variables#
For production use, clients should send query variables separately from the query string. This prevents injection and allows query caching.
curl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{ "query": "query GetProduct($id: Int!) { product(id: $id) { id name price } }", "variables": {"id": 1} }'{ "data": { "product": { "id": 1, "name": "Wireless Keyboard", "price": 79.99 } }}The query uses $id as a placeholder, and the actual value comes from the variables object. This is safer and more efficient than string interpolation.
11. Exercise: Build a GraphQL API for a Blog#
Build a complete GraphQL API for a blog with posts, authors, and comments.
Requirements#
- Schema -- Define types for
User,Post, andCommentwith the following fields:User: id, name, email, posts (nested)Post: id, title, body, published, author (nested), comments (nested), commentCount (computed)Comment: id, authorName, body, createdAt
- Queries:
posts-- List all published postspost(id: Int!)-- Get a single post by IDuser(id: Int!)-- Get a user with their posts
- Mutations:
createPost(userId: Int!, title: String!, body: String!, published: Boolean)-- Create a new postaddComment(postId: Int!, authorName: String!, body: String!)-- Add a comment to a post
- Use the ORM models from Chapter 6 (User, Post, Comment).
Test with these queries:#
# List published posts with author namescurl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ posts { id title author { name } commentCount } }"}'โ# Get a specific post with all commentscurl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{"query": "{ post(id: 1) { title body author { name email } comments { authorName body } } }"}'โ# Create a postcurl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{"query": "mutation { createPost(userId: 1, title: \"GraphQL is great\", body: \"Here is why...\", published: true) { id title } }"}'โ# Add a commentcurl -X POST http://localhost:7146/graphql \ -H "Content-Type: application/json" \ -d '{"query": "mutation { addComment(postId: 1, authorName: \"Carol\", body: \"Nice article!\") { id authorName } }"}'12. Solution#
Schema#
Create src/graphql/blog_schema.graphql:
type User { id: Int! name: String! email: String! posts: [Post!]!}โtype Post { id: Int! title: String! body: String! published: Boolean! author: User! comments: [Comment!]! commentCount: Int! createdAt: String}โtype Comment { id: Int! authorName: String! body: String! createdAt: String}โtype Query { posts: [Post!]! post(id: Int!): Post user(id: Int!): User}โtype Mutation { createPost(userId: Int!, title: String!, body: String!, published: Boolean): Post! addComment(postId: Int!, authorName: String!, body: String!): Comment!}Resolvers#
Create src/graphql/blog_resolvers.py:
from tina4_python.graphql import GraphQLโโ@GraphQL.resolve("Query", "posts")async def resolve_posts(root, args): posts, count = Post.where("published = ?", [1]) return [p.to_dict() for p in posts]โโ@GraphQL.resolve("Query", "post")async def resolve_post(root, args): post = Post.find(args["id"]) return post.to_dict() if post else Noneโโ@GraphQL.resolve("Query", "user")async def resolve_user(root, args): user = User.find(args["id"]) return user.to_dict() if user else Noneโโ@GraphQL.resolve("Post", "author")async def resolve_post_author(post, args): user = User.find(post["user_id"]) return user.to_dict() if user else Noneโโ@GraphQL.resolve("Post", "comments")async def resolve_post_comments(post, args): comments, count = Comment.where("post_id = ?", [post["id"]]) return [c.to_dict() for c in comments]โโ@GraphQL.resolve("Post", "commentCount")async def resolve_post_comment_count(post, args): comments, count = Comment.where("post_id = ?", [post["id"]]) return countโโ@GraphQL.resolve("User", "posts")async def resolve_user_posts(user, args): posts, count = Post.where("user_id = ?", [user["id"]]) return [p.to_dict() for p in posts]โโ@GraphQL.resolve("Mutation", "createPost")async def resolve_create_post(root, args): user = User.find(args["userId"])โ if user is None: raise Exception("User not found")โ post = Post() post.user_id = args["userId"] post.title = args["title"] post.body = args["body"] post.published = bool(args.get("published", False)) post.save()โ return post.to_dict()โโ@GraphQL.resolve("Mutation", "addComment")async def resolve_add_comment(root, args): post = Post.find(args["postId"])โ if post is None: raise Exception("Post not found")โ comment = Comment() comment.post_id = args["postId"] comment.author_name = args["authorName"] comment.body = args["body"] comment.save()โ return comment.to_dict()Expected output for { posts { id title author { name } commentCount } }:
{ "data": { "posts": [ { "id": 1, "title": "My First Post", "author": {"name": "Alice"}, "commentCount": 1 }, { "id": 2, "title": "GraphQL is great", "author": {"name": "Alice"}, "commentCount": 0 } ] }}13. Input Validation#
Tina4's GraphQL engine validates arguments against their declared types before the resolver runs. If validation fails, the resolver is skipped and a clean error is returned.
What Gets Validated#
- Non-null enforcement:
ID!means the argument cannot be null or empty - Scalar type checking:
Intmust be numeric,Booleanmust be bool,Stringmust be scalar - Type coercion: string
"123"coerces to Int123, string"true"coerces to BooleanTrue - List item validation:
[Int!]checks each item in the list
Example#
gql.schema.add_query("user", {"id": "ID!"}, "User", lambda r, a, c: find_user(a["id"]))Query with missing required argument:
{ user { id name } }Response:
{ "data": { "user": null }, "errors": [ { "message": "Argument 'id' on field 'user' is required (type: ID!)", "path": ["user"] } ]}The resolver never runs. The client gets a clear error describing what went wrong and where.
14. Field-Level Auth Directives#
Control access to individual fields using directives. No middleware setup, no wrapper functions: add a directive to the query and Tina4 checks the context.
Available Directives#
| Directive | Meaning |
|---|---|
@auth | Field requires any authenticated user |
@role(role: "admin") | Field requires a specific role |
@guest | Field is only accessible to unauthenticated users |
Passing Auth Context#
The execute() method accepts a context parameter. Pass the authenticated user from your request handler:
from tina4_python import getfrom tina4_python.graphql import GraphQLโgql = GraphQL()โ@get("/api/graphql")async def graphql_handler(request, response): body = request.body context = {} if request.user: context["user"] = {"id": request.user.id, "role": request.user.role}โ result = gql.execute(body["query"], body.get("variables"), context) return response.json(result)Using Directives in Queries#
# Only authenticated users can see this field{ profile @auth { id name email } }โ# Only admins can see salary{ employees { name salary @role(role: "admin") } }โ# Only guests see the registration prompt{ registrationBanner @guest }When a directive check fails, the field returns null and is silently excluded from the response. No error is added; the field simply doesn't appear, as if it doesn't exist for that user.
15. Gotchas#
1. Schema File Not Found#
Problem: Queries return errors about unknown types or fields.
Cause: The .graphql file is not in src/graphql/, or has a syntax error that prevents parsing.
Fix: Make sure schema files are in src/graphql/ and end with .graphql. Check for syntax errors -- a missing ! or unmatched brace will cause the entire schema to fail silently.
2. Resolver Not Called#
Problem: A query returns null even though data exists.
Cause: The resolver is not registered, or the type/field names do not match the schema exactly. GraphQL is case-sensitive.
Fix: Verify that @GraphQL.resolve("Query", "products") matches type Query { products: ... } exactly. Check capitalization.
3. Nested Resolver Returns Wrong Data#
Problem: post.author returns the entire post instead of the user.
Cause: The nested resolver receives the parent object as its first argument (post), not a fresh model. If you return the parent instead of loading the related record, you get the wrong data.
Fix: In a nested resolver, always load the related record from the database using the foreign key from the parent:
@GraphQL.resolve("Post", "author")async def resolve_post_author(post, args): user = User.find(post["user_id"]) # Use the foreign key from the parent return user.to_dict() if user else None4. Mutation Input Not Parsed#
Problem: args["input"] is None inside a mutation resolver.
Cause: The mutation signature in the schema uses an input type, but the client query passes arguments directly instead of wrapping them in an input object.
Fix: Match the query to the schema. If the schema says createProduct(input: ProductInput!), the client must send createProduct(input: { name: "Widget", price: 9.99 }), not createProduct(name: "Widget", price: 9.99).
5. N+1 Query Problem#
Problem: Loading 50 posts with their authors generates 51 database queries (1 for posts + 50 for authors).
Cause: Each nested resolver runs independently. When you resolve author for each post, that is one query per post.
Fix: Use the include parameter in your ORM queries to eager-load relationships. Tina4's GraphQL engine injects the current selection set into context as context["__selections"], so fromOrm resolvers can detect which relationships the client requested and batch-load them:
# The fromOrm resolver automatically checks __selections and eager-loadsgql.schema.from_orm(Post) # Generates queries that use include= when sub-fields match relationshipsFor custom resolvers, check context.get("__selections") and pre-load related data in a single query.
6. GraphQL Playground Returns 404#
Problem: /graphql/playground returns a 404 error.
Cause: TINA4_DEBUG is set to false. The playground is only available in debug mode.
Fix: Set TINA4_DEBUG=true in your .env file. The playground is intentionally disabled in production.
7. Type Mismatch Between Schema and Resolver#
Problem: A field declared as Int! in the schema receives a string from the resolver.
Cause: Python's dynamic typing means your resolver might return "42" (a string) for an Int! field.
Fix: Tina4's input validation now coerces argument types automatically (string "123" becomes Int 123). For return values, to_dict() handles type casting for ORM model properties. For computed fields, cast explicitly:
return { "id": int(product.id), "price": float(product.price), "inStock": bool(product.in_stock)}14. SOAP / WSDL Services#
What is SOAP?#
SOAP (Simple Object Access Protocol) is an XML-based messaging protocol for exchanging structured data between systems. It predates REST and GraphQL, but remains common in enterprise integrations -- banking, healthcare, government, and ERP systems all rely on SOAP services.
A WSDL (Web Services Description Language) file describes a SOAP service: what operations are available, what parameters they accept, and what they return. Clients use the WSDL to auto-generate code for calling the service.
Tina4 includes a zero-dependency SOAP 1.1 server that generates WSDL definitions automatically from Python classes and type annotations. No XML authoring required.
Defining a SOAP Service#
Create a class that extends WSDL. Each method decorated with @wsdl_operation becomes a SOAP operation. The decorator takes a dict describing the response fields and their types.
from tina4_python.wsdl import WSDL, wsdl_operationfrom tina4_python.core.router import get, postโclass Calculator(WSDL): @wsdl_operation({"Result": int}) def Add(self, a: int, b: int): return {"Result": a + b}โ @wsdl_operation({"Result": int}) def Multiply(self, a: int, b: int): return {"Result": a * b}โ@get("/calculator")@post("/calculator")async def calculator_endpoint(request, response): service = Calculator(request) return response(service.handle())That is the entire service. The handle() method inspects the request:
- GET (or
?wsdlquery parameter) -- returns the auto-generated WSDL definition - POST with SOAP XML body -- parses the XML, finds the operation, converts parameters, calls the method, and returns a SOAP XML response
Type Annotations Map to XSD#
Python type annotations on method parameters control how incoming XML values are converted. The WSDL generator also uses them to produce correct XSD type declarations.
| Python type | XSD type |
|---|---|
str | xsd:string |
int | xsd:int |
float | xsd:double |
bool | xsd:boolean |
bytes | xsd:base64Binary |
List[T] | Element of type T (repeated) |
Optional[T] | Type T (nillable) |
A More Complete Example#
from typing import List, Optionalfrom tina4_python.wsdl import WSDL, wsdl_operationfrom tina4_python.core.router import get, postโclass UserService(WSDL): @wsdl_operation({"Name": str, "Email": str, "Active": bool}) def GetUser(self, user_id: int): user = User.find(user_id) if user: return { "Name": user.name, "Email": user.email, "Active": bool(user.active), } return {"Name": "", "Email": "", "Active": False}โ @wsdl_operation({"Total": int, "Average": float, "Error": Optional[str]}) def SumList(self, Numbers: List[int]): if not Numbers: return {"Total": 0, "Average": 0.0, "Error": "Empty list"} return { "Total": sum(Numbers), "Average": sum(Numbers) / len(Numbers), "Error": None, }โ@get("/api/users/soap")@post("/api/users/soap")async def user_soap(request, response): service = UserService(request) return response(service.handle())Lifecycle Hooks#
Override on_request and on_result to add validation, logging, or transformation around every operation call.
class AuditedService(WSDL): def on_request(self, request): print(f"SOAP request from {request.headers.get('x-forwarded-for', 'unknown')}")โ def on_result(self, result): result["Timestamp"] = datetime.now().isoformat() return resultโ @wsdl_operation({"Status": str, "Timestamp": str}) def Ping(self): return {"Status": "ok"}Testing with curl#
Fetch the WSDL definition:
curl http://localhost:7146/calculator?wsdlCall the Add operation with a SOAP request:
curl -X POST http://localhost:7146/calculator \ -H "Content-Type: text/xml" \ -d '<?xml version="1.0" encoding="UTF-8"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> <soap:Body> <Add> <a>5</a> <b>3</b> </Add> </soap:Body></soap:Envelope>'Response:
<?xml version="1.0" encoding="UTF-8"?><soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/"> <soap:Body> <AddResponse> <Result>8</Result> </AddResponse> </soap:Body></soap:Envelope>If the operation name is wrong or the XML is malformed, the service returns a SOAP fault:
<soap:Fault> <faultcode>Client</faultcode> <faultstring>Unknown operation: Subtract</faultstring></soap:Fault>When to Use SOAP vs REST vs GraphQL#
| Scenario | Use |
|---|---|
| New API for web/mobile clients | REST or GraphQL |
| Integrating with legacy enterprise systems (SAP, banks, government) | SOAP |
| Clients require a machine-readable service contract | SOAP (WSDL) |
| Simple internal microservices | REST |
| Clients with diverse data needs | GraphQL |
SOAP is rarely the right choice for new APIs, but when you need to expose a service that legacy systems can consume, Tina4 makes it straightforward -- define a class, annotate your types, and the framework handles the XML.