WealthDost is an Indian wealth management platform — portfolio tracking, investor onboarding, SEBI-compliant KYC, stock tips. When we were designing the API, we had two clients (a React Native mobile app and an internal web dashboard) that each needed different shapes of the same data. The mobile app wanted compact summaries. The web wanted nested breakdowns. We didn't want to version REST endpoints or maintain client-specific endpoint variants.

GraphQL solved that problem exactly. Here's what I'd keep and what I'd change.

Why we chose it

The pitch for GraphQL in this context is accurate: one schema, clients request exactly what they need, no over-fetching, strong typing on both ends. With graphql-code-generator, we generated typed hooks for the React Native app from the same schema that NestJS served. When a field was renamed, the TypeScript error appeared in both places simultaneously. That is genuinely good.

Code-first with @nestjs/graphql gave us a reasonable DX:

@ObjectType()
export class Portfolio {
    @Field(() => ID)
    id: string;

    @Field(() => Float)
    totalValue: number;

    @Field(() => [Holding])
    holdings: Holding[];
}

@Resolver(() => Portfolio)
export class PortfolioResolver {
    @Query(() => Portfolio)
    async portfolio(@Args("id") id: string): Promise<Portfolio> {
        return this.portfolioService.findById(id);
    }
}

N+1 hit us on day three

The first time we ran a query that fetched a list of portfolios with their holdings, we watched 50 portfolios trigger 50 separate holdings queries. Classic N+1. We caught it in development, but only because we had query logging on.

DataLoader is the fix, and it should be the first thing you set up — before you write your first resolver:

@Injectable({ scope: Scope.REQUEST })
export class HoldingsLoader {
    constructor(private holdingsService: HoldingsService) {}

    readonly batchLoadFn = async (portfolioIds: readonly string[]) => {
        const holdings = await this.holdingsService.findByPortfolioIds([...portfolioIds]);
        const map = new Map(portfolioIds.map(id => [id, holdings.filter(h => h.portfolioId === id)]));
        return portfolioIds.map(id => map.get(id) ?? []);
    };

    loader = new DataLoader(this.batchLoadFn);
}

REQUEST scope is important — you want a fresh DataLoader per request so the cache doesn't bleed across users.

Field-level auth for SEBI compliance

SEBI regulations restrict who can see certain investment data. Unverified users can browse the platform but can't see their actual portfolio value or receive stock recommendations until KYC is complete.

We handled this with a custom field-level guard:

function KycRequired(): MethodDecorator {
    return UseGuards(KycGuard);
}

@Resolver(() => Portfolio)
export class PortfolioResolver {
    @Query(() => Portfolio)
    async portfolio(...) { ... }

    @ResolveField(() => Float)
    @KycRequired()
    async totalValue(@Parent() portfolio: Portfolio): Promise<number> {
        return this.portfolioService.getTotalValue(portfolio.id);
    }
}

If the guard throws, that field returns null and the error appears in the GraphQL errors array — the rest of the response still returns. That behavior is actually useful for compliance: you can return a partial portfolio (name, type, creation date) while withholding regulated fields until KYC passes.

What I'd do differently

Error handling is worse than REST. GraphQL always returns HTTP 200. Your API client, logging infrastructure, and alerting all have to look inside the response body to detect errors. We had a Datadog alert on HTTP 5xx that did nothing for GraphQL errors for three weeks before we noticed. Set up explicit logging for the errors field in every response.

Subscriptions added complexity we underestimated. Real-time portfolio value updates via subscriptions sounded clean in the planning doc. In practice, we needed WebSocket infrastructure, separate auth handling, reconnect logic on the client, and the subscription resolvers are harder to test than queries. We eventually moved real-time updates to polling for most cases and reserved subscriptions for the one screen that actually needed live data.

graphql-codegen is worth the setup cost. The typed client generation saved weeks of time and caught type regressions across the entire client/server boundary. If you're not running codegen, you're giving up most of the value of GraphQL's type system.

Would I use it again? For this specific shape of problem — multiple clients, complex nested data, typed codegen — yes. For a single-client CRUD API, the overhead isn't worth it. REST is simpler and the tooling is more predictable.