Skip to content
Back to blog

GraphQL in Practice: Two Products, One Schema

6 min read
GraphQLArchitectureBackendScaling

When a product grows and splits into two, the fatal question arrives: two backends or one? On Crafteo — an expert freelance platform — the marketplace (Discover) and the management tool (Manage) had fundamentally different needs. A shared GraphQL schema was the answer. But making it work required more discipline than I expected.

Fictional product. Real engineering patterns.

Two products, two access patterns

Discover is a read-heavy product. Companies browse weekly selections of pre-vetted freelance experts, filter by skills, review profiles, compare candidates. Queries are broad, frequent, and tolerant of slight latency. A company searching for "senior data engineers available next month" triggers a query that scans thousands of expert profiles, applies filters, and returns a ranked list. With over 300 daily active clients and 20,000+ expert profiles in the database, these read patterns generated substantial load.

Manage is the opposite. Teams track the mission lifecycle — qualification, matching, contract signing, execution, invoicing. Mutations are frequent, status changes constantly, and consistency is critical. When an ops manager marks a mission as "contract signed," the expert's availability must update immediately, the client must be notified, and the invoicing workflow must be triggered. A stale read here is not a minor inconvenience — it is an operational error.

Both products queried the same MongoDB collections. The same experts collection served Discover's browsing queries and Manage's profile mutations. The same missions collection handled Discover's matching results and Manage's lifecycle tracking. And that is where the tension began.

GraphQL as the arbitration layer

Loading diagram…

The decision to share a single Apollo Server was pragmatic: the domain entities — experts, companies, missions — were genuinely shared. Duplicating them across two backends would have meant synchronizing state between two databases, which is a harder problem than sharing a schema.

The schema was designed around these shared entities with product-specific extensions. The expertProfile query served both products, but the resolved fields differed depending on context. Discover needed availability, skills, and a brief summary. Manage needed contract history, mission status, and invoicing details. GraphQL's ability to let the client select exactly the fields it needs made this natural — each product queried the same type but fetched only its relevant slice.

Where the two products fought — and how the schema refereed

The core challenge was that every optimization for one product risked degrading the other.

From Discover's side, the threat was volume. A single search page triggered queries across thousands of expert profiles with nested skills, availability windows, and location data. Without guardrails, these broad reads would saturate MongoDB connections and starve Manage's mutations of write capacity. Query complexity analysis at the GraphQL layer was the first defense: every query was scored based on the fields requested and the depth of nested resolvers. Queries exceeding a complexity threshold were rejected before they hit MongoDB. This caught the edge cases — a Discover client accidentally requesting nested mission histories for every expert in a list — that would have generated thousands of database calls.

From Manage's side, the threat was consistency. A mission status change needed to propagate immediately — the expert's availability, the client notification, the invoicing trigger. Manage could not tolerate eventual consistency, but Discover's heavy read load on the same collections created lock contention. The DataLoader pattern helped on both sides: it batched and deduplicated database calls within a single request. When a list query resolved 50 expert profiles, DataLoader consolidated 50 individual MongoDB reads into a single batch query, freeing connection capacity for Manage's writes.

MongoDB indexes were the quiet battlefield. Compound indexes on skills, availability, and location served Discover's filtered searches. Indexes on mission status and update timestamps served Manage's frequent writes and status queries. But adding an index that sped up Discover's reads could slow Manage's write throughput — every write must update every matching index. The index strategy was not set once — it evolved as we monitored actual query patterns in production, always balancing one product's gain against the other's cost.

Schema evolution: where the tension became permanent

The runtime protections above handled day-to-day traffic. But the deeper conflict was structural: when two products consume the same schema, a change that serves one can silently break the other.

Discover wanted richer expert profiles — more filterable fields, nested portfolio data, availability forecasts. Every new field meant more data for Discover's broad queries to traverse. Manage wanted leaner mutations — fast writes, minimal validation overhead, tight response times. A field that enriched Discover's browsing experience added write overhead to Manage's mission lifecycle.

We established strict schema evolution rules. Adding fields was always safe. Renaming or removing fields required a deprecation cycle — mark the old field as deprecated, ensure both consumers migrated, then remove in a later release. Type changes on shared fields were forbidden without a migration plan covering both products.

Cross-product integration tests were the safety net. Every schema change triggered a test suite that ran queries from both Discover and Manage against the updated schema. These tests caught regressions that unit tests missed — a field that Manage stopped using but Discover still depended on, or a new nullable field that broke Discover's non-null assumption.

What I take away

GraphQL is not magic — it is a contract. Its real value emerges when that contract is maintained with discipline: complexity analysis to protect performance, DataLoader to prevent N+1, schema evolution rules to prevent cross-product regressions, integration tests to verify everything holds together.

Without that discipline, the shared schema becomes a coupling point, not a unification point. The two-product architecture worked because we treated the schema as shared infrastructure with the same rigor as a database migration — not as a convenience to avoid duplicating code.

The platform served 300+ daily active clients across both products from a single API. But the metric I watched most closely was not throughput — it was the number of schema changes that shipped without breaking either consumer. That number needed to stay at 100%, and it did.