Isaac.

aspnet

ASP.NET Core GraphQL

Implement GraphQL APIs in ASP.NET Core applications.

By Emem IsaacApril 17, 202518 min read
#aspnet#graphql#api
Share:

A Simple Explanation

The Restaurant Menu Analogy

Imagine two restaurants:

Traditional Restaurant (REST):

  • You order "Meal #1"
  • It comes with burger, fries, coleslaw, and dessert
  • You only wanted burger and fries
  • You pay for the whole meal
  • Leftover food is wasted

But if you want extra items:

  • You need to order "Meal #2" (completely different)
  • Or order individual items from different menus
  • Multiple trips to the counter

Smart Restaurant (GraphQL):

  • You say: "I want a burger, fries, and a drink"
  • That's exactly what you get
  • Nothing extra, no wasted food
  • If you change your mind, you just ask for something different
  • Same menu works for everyone

GraphQL in APIs:

  • Traditional REST: Fixed responses (you get all fields even if you only want some)
  • GraphQL: You request specific fields, get only those fields
  • Bandwidth: Less data transferred (mobile users love this)
  • Multiple requests: One query instead of many

Why GraphQL Exists

The Problem with REST APIs

Imagine you're building a mobile app showing a user's profile. You need:

  1. User's name
  2. User's email
  3. User's avatar
  4. Count of user's followers

With REST, the /api/users/{id} endpoint returns:

{
  "id": 1,
  "name": "John",
  "email": "john@example.com",
  "avatar": "...",
  "phone": "555-1234",
  "address": "123 Main St",
  "bio": "...",
  "birthDate": "1990-01-01",
  "followers": 1000000,
  "following": 500,
  // ... 20 more fields
}

Problem 1: Over-fetching - You got all 28 fields but only needed 4. Wasted 85% of bandwidth!

Then you need follower count, so you call /api/users/{id}/followers/count. That's another request.

Problem 2: Under-fetching - You need data from multiple sources, so you make 3-5 separate API calls:

  • GET /users/1
  • GET /users/1/followers
  • GET /users/1/posts
  • GET /posts/latest

For mobile apps on 4G, this is slow. For international users on slow connections, it's painful.

Problem 3: Version management - Your company grows. You want to add new fields:

/api/v1/users     (old, with old fields)
/api/v2/users     (new, with new fields)
/api/v3/users     (newer, with even more fields)

Now you maintain 3 versions. Clients don't upgrade. Nightmare.

GraphQL Solution

With GraphQL, client says:

query {
  user(id: 1) {
    name
    email
    avatar
    followers
  }
}

Server responds with exactly those 4 fields, nothing more:

{
  "user": {
    "name": "John",
    "email": "john@example.com",
    "avatar": "...",
    "followers": 1000000
  }
}

Benefits:

  • ✓ No over-fetching (25% payload size)
  • ✓ No under-fetching (one query gets everything)
  • ✓ No versioning (same endpoint forever)
  • ✓ Clients specify what they need
  • ✓ Mobile apps consume less battery and data

Content Overview

Table of Contents

  • How GraphQL Works (Conceptually)
  • Setting Up HotChocolate
  • Defining Types and Queries
  • Understanding Query Execution
  • Mutations (Creating & Modifying Data)
  • Nested Queries & Relationships
  • DataLoader for N+1 Prevention
  • Subscriptions (Real-time Updates)
  • Error Handling & Validation
  • Real-World Use Cases
  • Performance Considerations
  • GraphQL vs REST Comparison
  • Related Concepts to Explore

How GraphQL Works (Conceptually)

The Three-Layer System:

Layer 1: Schema

type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
}

type Query {
  user(id: ID!): User
  posts: [Post!]!
}

This is like a contract: "Here's what data I have and what you can ask for"

Layer 2: Request

query {
  user(id: "1") {
    name
    email
    posts {
      title
    }
  }
}

Client says: "Give me this user's name, email, and titles of their posts"

Layer 3: Response

{
  "data": {
    "user": {
      "name": "John",
      "email": "john@example.com",
      "posts": [
        { "title": "GraphQL Guide" },
        { "title": "REST API Tips" }
      ]
    }
  }
}

Server responds with EXACTLY what was requested

Key Difference from REST:

  • REST: Client hits endpoint, server decides what to return
  • GraphQL: Client specifies what fields needed, server returns only those

1. Setting Up HotChocolate

What is HotChocolate?

HotChocolate is a GraphQL server library for .NET. It:

  • Takes your C# classes and converts them to GraphQL schema automatically
  • Handles query parsing and execution
  • Provides a playground to test queries (like Postman for GraphQL)
  • Integrates seamlessly with ASP.NET Core

Installation:

dotnet add package HotChocolate.AspNetCore

Configuration in Program.cs:

var builder = WebApplicationBuilder.CreateBuilder(args);

// Add GraphQL services
builder
    .Services
    .AddGraphQLServer()                    // Initialize GraphQL
    .AddQueryType<Query>()                 // What clients can query
    .AddMutationType<Mutation>()           // What clients can modify
    .AddSubscriptionType<Subscription>()   // Real-time updates
    .AddTransactionalOperationMiddleware() // Transaction support
    .ModifyRequestOptions(opt =>           // Configure behavior
    {
        opt.MaximumAllowedOperationComplexity = 50;
    });

var app = builder.Build();

// Map GraphQL endpoint
app.MapGraphQL("/graphql");  // Accessible at /graphql
app.MapGraphQLVoyager();      // Visual explorer at /graphql-voyager

app.Run();

After Setup:

  • Visit https://localhost:5001/graphql to access GraphQL Playground
  • Write queries and test them instantly
  • Schema documentation is auto-generated

2. Defining Types and Queries

Creating Your First GraphQL Type

C# class → GraphQL type (automatically!)

// This C# class...
public class Product
{
    public int Id { get; set; }                // Becomes: id: Int!
    public string Name { get; set; }           // Becomes: name: String!
    public decimal Price { get; set; }         // Becomes: price: Float!
    public string Description { get; set; }    // Becomes: description: String!
    public int Stock { get; set; }             // Becomes: stock: Int!
}

// ...automatically generates this GraphQL schema:
// type Product {
//   id: Int!
//   name: String!
//   price: Float!
//   description: String!
//   stock: Int!
// }

Creating Query Methods

public class Query
{
    private readonly IDbContext _db;
    
    public Query(IDbContext db)
    {
        _db = db;  // Dependency injection
    }

    // Becomes GraphQL query: product(id: Int!): Product
    public Product GetProduct(int id)
    {
        return _db.Products.FirstOrDefault(p => p.Id == id);
    }

    // Becomes GraphQL query: products: [Product!]!
    public IEnumerable<Product> GetProducts()
    {
        return _db.Products.ToList();
    }
    
    // Becomes GraphQL query: productsByCategory(categoryId: Int!): [Product!]!
    public IEnumerable<Product> GetProductsByCategory(int categoryId)
    {
        return _db.Products
            .Where(p => p.CategoryId == categoryId)
            .ToList();
    }
}

Making Your First Query

In GraphQL Playground, you write:

query GetAllProducts {
  products {
    id
    name
    price
  }
}

The Magic: You asked for only 3 fields (id, name, price). The response includes only those fields:

{
  "data": {
    "products": [
      {
        "id": 1,
        "name": "Laptop",
        "price": 999.99
      },
      {
        "id": 2,
        "name": "Mouse",
        "price": 19.99
      }
    ]
  }
}

Notice: description and stock are NOT in response because we didn't ask for them.

Compare to REST:

# REST: You get everything
GET /api/products
# Response: 100 fields per product, 50KB payload

# GraphQL: You get what you ask for
POST /graphql
query { products { id name price } }
# Response: 3 fields per product, 2KB payload

With Arguments

public class Query
{
    // Becomes: products(skip: Int!, take: Int!): [Product!]!
    public IEnumerable<Product> GetProducts(
        int skip = 0,
        int take = 10)
    {
        return _db.Products
            .Skip(skip)
            .Take(take)
            .ToList();
    }
    
    // Becomes: productsBetween(minPrice: Float!, maxPrice: Float!): [Product!]!
    public IEnumerable<Product> GetProductsBetween(
        decimal minPrice,
        decimal maxPrice)
    {
        return _db.Products
            .Where(p => p.Price >= minPrice && p.Price <= maxPrice)
            .ToList();
    }
}

Query them like this:

query {
  productsBetween(minPrice: 100, maxPrice: 500) {
    name
    price
  }
}

3. Understanding Query Execution

How GraphQL Processes a Query

When you send a query:

query {
  product(id: 1) {
    name
    category {
      name
    }
  }
}

GraphQL does:

  1. Parse: Check query syntax is valid
  2. Validate: Check query matches schema
  3. Execute:
    • Call Query.Product(id: 1) → Gets Product with id=1
    • Call Product.Name → Gets product's name
    • Call Product.Category → Gets associated category
    • Call Category.Name → Gets category name
  4. Return: Sends only requested fields

Field Resolvers

By default, GraphQL maps properties automatically:

public class Product
{
    public int Id { get; set; }           // Auto-resolves from property
    public string Name { get; set; }      // Auto-resolves from property
    
    // But related data needs custom resolvers:
    [GraphQLIgnore]
    public int CategoryId { get; set; }   // Don't expose in GraphQL
    
    // Custom resolver for related category
    public async Task<Category> GetCategory(
        [Service] IDbContext db)          // Inject dependencies
    {
        return await db.Categories
            .FirstOrDefaultAsync(c => c.Id == CategoryId);
    }
}

Now clients can query nested data:

query {
  products {
    name
    category {
      name
    }
  }
}

4. Mutations (Creating & Modifying Data)

Mutations are how you modify data in GraphQL (like POST/PUT in REST).

public class Mutation
{
    public Product CreateProduct(
        string name, 
        decimal price, 
        string description)
    {
        var product = new Product 
        { 
            Name = name, 
            Price = price, 
            Description = description 
        };
        
        _db.Products.Add(product);
        _db.SaveChanges();
        
        return product;
    }

    public bool UpdateProduct(int id, string name, decimal price)
    {
        var product = _db.Products.FirstOrDefault(p => p.Id == id);
        if (product == null) return false;

        product.Name = name;
        product.Price = price;
        _db.SaveChanges();
        
        return true;
    }

    public bool DeleteProduct(int id)
    {
        var product = _db.Products.FirstOrDefault(p => p.Id == id);
        if (product == null) return false;

        _db.Products.Remove(product);
        _db.SaveChanges();
        
        return true;
    }
}

GraphQL Mutation Example:

mutation {
  createProduct(
    name: "Laptop"
    price: 999.99
    description: "High-performance laptop"
  ) {
    id
    name
  }
}

5. Nested Queries & Relationships

The N+1 Problem in Action

Imagine you want products with their categories:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }
    
    // Without optimization, this queries the database for EVERY product
    public Category Category { get; set; }  // ❌ Causes N+1 queries
}

When you query 100 products:

  • Query 1: Get 100 products
  • Query 2-101: For each product, get its category
  • Total: 101 database queries! 🔴

For 1,000 products? 1,001 queries. Disaster.

Bad Performance Flow:

GraphQL Query comes in
↓
Resolve 1,000 products (Query 1)
↓
For each product, resolve category (Query 2, 3, 4... 1,001)
↓
Database is overloaded
↓
App becomes slow

6. DataLoader for N+1 Prevention

The DataLoader Solution

Instead of loading categories one-by-one, batch them:

public class Product
{
    public int Id { get; set; }
    public string Name { get; set; }
    public int CategoryId { get; set; }

    // Instead of direct property, use DataLoader
    public async Task<Category> GetCategory(
        [Service] CategoryDataLoader loader)  // Inject DataLoader
    {
        return await loader.LoadAsync(CategoryId);  // Queue for batch loading
    }
}

// Define the DataLoader
public class CategoryDataLoader : BatchDataLoader<int, Category>
{
    private readonly IDbContextFactory<AppDbContext> _contextFactory;

    public CategoryDataLoader(IDbContextFactory<AppDbContext> contextFactory)
        : base()
    {
        _contextFactory = contextFactory;
    }

    // Called once with ALL category IDs needed
    protected override async Task<IReadOnlyDictionary<int, Category>> LoadBatchAsync(
        IReadOnlyList<int> keys,  // All category IDs: [5, 7, 12, 5, 7, ...]
        CancellationToken ct)
    {
        var db = await _contextFactory.CreateDbContextAsync(ct);
        var uniqueKeys = keys.Distinct().ToList();  // Remove duplicates: [5, 7, 12]
        
        // Single query: Get all 3 categories at once
        var categories = await db.Categories
            .Where(c => uniqueKeys.Contains(c.Id))
            .ToDictionaryAsync(c => c.Id, ct);

        return categories;
    }
}

// Register in Program.cs
builder.Services
    .AddGraphQLServer()
    .AddQueryType<Query>()
    .AddDataLoader<CategoryDataLoader>();

How DataLoader Works Internally

GraphQL executes query for 1,000 products
↓
For each product's Category field:
  - Don't fetch immediately
  - Add (categoryId) to "batch queue"
  - Queue: [5, 7, 12, 5, 7, ...] (duplicates allowed)
↓
All fields resolved, time to execute batches
↓
DataLoader batches: [5, 7, 12, 5, 7, ...] → unique: [5, 7, 12]
↓
Single query: SELECT * FROM Categories WHERE Id IN (5, 7, 12)
↓
Return results: { 5: Category5, 7: Category7, 12: Category12 }
↓
Each product gets its category

Performance Comparison:

Without DataLoader:
- 1,001 database queries
- Response time: 5 seconds
- Database CPU: 95%

With DataLoader:
- 2 database queries (products + categories)
- Response time: 50ms
- Database CPU: 5%

When to Use DataLoader

✓ When loading related data (products → categories) ✓ When same ID requested multiple times ✓ When you have many-to-one relationships ✗ When you only load a few items ✗ When data is already in memory/cache

7. Subscriptions (Real-time Updates)

Get notified when data changes:

public class Subscription
{
    public async IAsyncEnumerable<Product> ProductCreated(
        [Service] ProductUpdatedTopic topic)
    {
        await foreach (var product in topic.ProductCreated())
        {
            yield return product;
        }
    }
}

// In mutation
public class Mutation
{
    public async Task<Product> CreateProduct(
        string name,
        [Service] ProductUpdatedTopic topic)
    {
        var product = new Product { Name = name };
        _db.Products.Add(product);
        await _db.SaveChangesAsync();

        await topic.OnProductCreated(product);
        return product;
    }
}

8. Error Handling & Validation

GraphQL provides detailed error information:

public class Query
{
    public Product GetProduct(int id)
    {
        var product = _db.Products.FirstOrDefault(p => p.Id == id);
        
        if (product == null)
        {
            throw new GraphQLException(
                new Error(
                    $"Product with ID {id} not found",
                    extensions: new Dictionary<string, object>
                    {
                        { "code", "NOT_FOUND" },
                        { "productId", id }
                    }
                )
            );
        }

        return product;
    }
}

9. Real-World Use Cases

Use Case 1: Mobile App - Twitter Clone

Home Feed Page needs:
- Tweet ID, text, timestamp
- Author (name, avatar)
- Like count, reply count

Query:
```graphql
query HomeFeed {
  tweets(limit: 20) {
    id
    text
    timestamp
    author { name avatar }
    likeCount
    replyCount
  }
}

Response: ~40KB (efficient for mobile)

Profile Page needs different fields:

  • Tweet ID, text
  • Full author profile
  • Media urls
  • Engagement metrics

Query:

query UserProfile($userId: ID!) {
  user(id: $userId) {
    name
    bio
    followers
    tweets {
      id
      text
      mediaUrl
      likeCount
      replyCount
    }
  }
}

Response: Different structure, same endpoint

Benefit: Same API endpoint (/graphql), but mobile saves 60% bandwidth vs. REST


Use Case 2: E-commerce Dashboard

Manager's dashboard needs:

query Dashboard {
  totalRevenue: summaryStats { totalSales monthlyGrowth }
  topProducts: products(limit: 5, orderBy: SALES) {
    name
    sales
    revenue
  }
  recentOrders: orders(limit: 10) {
    id
    customer { name email }
    total
    status
  }
}

Instead of 3 REST endpoints:

  • GET /api/stats
  • GET /api/products/top
  • GET /api/orders/recent

One GraphQL query returns everything.


Use Case 3: Real-time Multiplayer Game

subscription GameUpdates($gameId: ID!) {
  gameStateChanged(gameId: $gameId) {
    players { id position health }
    items { id x y }
    events { type timestamp }
  }
}

When any change happens (player moved, item picked up), all clients subscribed to this game get instant update via WebSocket.


Use Case 4: Multi-tenant SaaS Platform

Different customers see different data:

query CompanyData($companyId: ID!) {
  company(id: $companyId) {
    name
    employees { id name role salary }
    projects { id name status }
    financials { revenue expenses profit }
  }
}
  • Customer A might want: name, employees, projects
  • Customer B might want: name, financials, projects
  • Same query endpoint, different responses based on permissions

Use Case 5: Backend-for-Frontend (BFF)

You have one backend API serving:

  • Web app (desktop browser)
  • Mobile app (iOS/Android)
  • Tablet app
  • Smart TV app

Each platform has different data needs and network conditions. Instead of 4 different APIs, one GraphQL endpoint optimized for each client's query.


Use Case 6: Microservices Integration

You have:

  • User Service
  • Product Service
  • Order Service
  • Payment Service

GraphQL gateway federates them:

query OrderDetails($orderId: ID!) {
  order(id: $orderId) {
    id
    customer { id name email }  # From User Service
    items { id title price }    # From Product Service
    payment { status method }   # From Payment Service
  }
}

Clients see single API. Internally, GraphQL calls 3 services. Hides complexity.

10. Performance Considerations

Common Performance Issues

Problem 1: Expensive Nested Queries

A query like this could be devastating:

query {
  users {
    id
    posts {
      id
      comments {
        id
        author {
          id
          posts {
            id
            comments { id }
          }
        }
      }
    }
  }
}

This could request millions of records. Solution: Query complexity analysis

builder.Services
    .AddGraphQLServer()
    .AddMaxComplexityRules(maxComplexity: 50)  // Block complex queries

Problem 2: Unbounded Lists

query {
  allUsers {  # What if there's 1 million users?
    id name posts { id }
  }
}

Solution: Require pagination

public class Query
{
    public Connection<User> GetUsers(
        int first = 10,    // Default to 10
        int? after = null) // Cursor-based pagination
    {
        // Return only 10 users, provide cursor for next batch
    }
}

Problem 3: Missing Indexes

GraphQL might query data in unexpected ways. Ensure your database indexes are good.

Solution: Caching

public class Query
{
    [GraphQLType("User")]
    [Cached(duration: 300)]  // Cache for 5 minutes
    public User GetUser(int id)
    {
        return _db.Users.FirstOrDefault(u => u.Id == id);
    }
}

11. GraphQL vs REST Comparison

| Aspect | GraphQL | REST | |--------|---------|------| | Fetching Data | Exact fields requested | Fixed response structure | | Multiple Resources | Single query | Multiple endpoints (N requests) | | Versioning | No versions needed | v1, v2, v3... | | Caching | More complex (not HTTP native) | HTTP caching works naturally | | Learning Curve | Steeper | Easier for basics | | Bandwidth | Optimal (only requested fields) | Often over-fetched | | Performance | Fast with proper optimization | Consistent | | Tooling | Excellent (Playground, voyager) | Simple (cURL, Postman) | | Type Safety | Strong schema validation | Manual validation | | Real-time | Native (subscriptions) | Need WebSockets separately |

When to Use Each

Use GraphQL When:

  • ✓ Mobile clients (bandwidth matters)
  • ✓ Multiple clients with different needs
  • ✓ Complex data relationships
  • ✓ Real-time updates needed
  • ✓ Internal APIs between services

Use REST When:

  • ✓ Simple CRUD operations
  • ✓ Heavy caching is important
  • ✓ Team unfamiliar with GraphQL
  • ✓ Public API for external developers (REST is more familiar)
  • ✓ Simple resource-based APIs

12. Related Concepts to Explore

GraphQL Core Concepts

  • SDL (Schema Definition Language) - Language for writing GraphQL schemas
  • Type System - Scalars, Objects, Interfaces, Unions, Enums
  • Directives - Annotations like @deprecated, @cacheControl
  • Aliases - Get same field with different names: { newName: oldField }
  • Fragments - Reuse query parts: fragment ProductFields on Product { id name }
  • Variables - Parameterize queries: query GetUser($id: ID!) { user(id: $id) { ... } }
  • Introspection - Query the schema itself
  • Custom Scalars - Beyond Int, Float, String, Boolean (e.g., DateTime, UUID)

Query Optimization

  • Query Complexity Analysis - Prevent expensive queries
  • Query Depth Limiting - Prevent deeply nested queries
  • Field Timeout - Kill slow field resolvers
  • Result Set Size Limiting - Cap returned records
  • Batch DataLoader - Prevent N+1 queries
  • Query Persisting - Pre-compile queries for security and performance
  • Operation Name Whitelisting - Only allow approved queries
  • Cost Analysis - Assign costs to fields, sum for query

Authorization & Security

  • Field-level Authorization - Some users can't see certain fields
  • Resolver-level Permissions - Check permissions in each resolver
  • Rate Limiting - Limit queries per user
  • Query Depth Limiting - Prevent DoS attacks
  • Authentication - JWT, OAuth integration
  • Input Validation - Sanitize query inputs
  • Directive-based Rules - @authorize(role: "Admin")

Advanced Features

  • Federation - Combine multiple GraphQL services (Apollo Federation)
  • Schema Stitching - Merge multiple GraphQL schemas
  • Apollo Gateway - Unified GraphQL API across services
  • Custom Directives - Create application-specific rules
  • Middleware - Transform data, validate, log
  • Error Handling - Custom error formats
  • Field Middleware - Intercept field resolution

Subscriptions & Real-time

  • WebSocket Transport - Real-time connection protocol
  • Context Broadcasting - Send updates to multiple subscribers
  • Event Streaming - Handle high-volume updates
  • Backpressure Handling - Manage slow subscribers
  • Connection Management - Handle client reconnects

Tools & Clients

  • Apollo Client - Popular JavaScript GraphQL client with caching
  • urql - Lightweight GraphQL client
  • React Query - Data fetching library (REST-focused but works with GraphQL)
  • GraphQL Playground - IDE for testing queries
  • GraphQL Voyager - Visual schema explorer
  • Postman - Supports GraphQL testing
  • GraphQL Code Generator - Generate types from schema

Testing

  • Unit Testing Resolvers - Test individual field resolvers
  • Integration Testing - Test full queries
  • Snapshot Testing - Compare query responses
  • Performance Testing - Measure query execution time
  • Security Testing - Test authorization rules
  • Schema Linting - Validate schema best practices

Server-Side Patterns

  • Relay Cursor Pagination - Standard pagination pattern
  • Global Object ID - Unique IDs across types
  • Connection Type - Standard way to return paginated results
  • Mutation Input Type - Bundle mutation parameters
  • Error Interface - Structured error responses
  • Search & Filtering - Input types for complex queries

Monitoring & Observability

  • Query Execution Time - Track slow queries
  • N+1 Detection - Identify missing optimizations
  • Usage Metrics - Popular fields, operations
  • Error Tracking - Log GraphQL errors
  • APM Integration - Application performance monitoring
  • Distributed Tracing - Track across services

Database Integration

  • EF Core with GraphQL - ORM + GraphQL
  • Lazy Loading Issues - N+1 problems with EF
  • Query Optimization - SelectAsync vs loading
  • Transactions - Ensuring data consistency
  • Caching Strategies - Redis, in-memory caching

Comparison Ecosystems

  • Apollo (JavaScript) - Full-featured GraphQL ecosystem
  • Relay (JavaScript) - Facebook's GraphQL client
  • Hot Chocolate (.NET) - Feature-rich GraphQL server
  • Hasura (Any DB) - Instant GraphQL API from database
  • FaunaDB - Database with native GraphQL
  • Shopify GraphQL API - Real-world example

Advanced Architectures

  • BFF (Backend-for-Frontend) - Separate GraphQL for each client
  • API Gateway - Single entry point for multiple services
  • Microservices GraphQL - GraphQL across services
  • Event-Driven Architecture - Subscriptions trigger events
  • CQRS (Command Query Responsibility Separation) - Separate read/write models
Share:

Written by Emem Isaac

Expert Software Engineer with 15+ years of experience building scalable enterprise applications. Specialized in ASP.NET Core, Azure, Docker, and modern web development. Passionate about sharing knowledge and helping developers grow.

Ready to Build Something Amazing?

Let's discuss your project and explore how my expertise can help you achieve your goals. Free consultation available.

💼 Trusted by 50+ companies worldwide | ⚡ Average response time: 24 hours