This article covers the technical decisions behind building this system. Each section explores a different layer of the architecture.
Start With the Domain
The most common mistake in GraphQL schema design is starting with the database. You end up with schemas that are just a thin wrapper over your tables — technically correct, but wrong for the domain.
For Paws Paradise, I started by listing every business concept that actually mattered:
Notice that Pricing isn't a table — it's a computation. Modeling it as a stored value would create sync issues every time a rule changed. The schema should reflect that.
Modeling Relationships
Relationships in GraphQL should reflect how the client actually needs the data — not how the database joins tables. This means thinking about query shapes before you write a single type.
For the booking flow, the client needs: the booking, the pet attached to it, the unit assigned, and the pricing breakdown — all in one query. So the types need to compose naturally:
The Unit type exposes currentOccupant — not a foreign key, but the resolved pet. This is intentional: the frontend never has to make a second query to find out who's in a room.
Enums as Business Rules
Enums are underused in most GraphQL schemas. They're not just convenience — they're business rules encoded at the type level. If a field can only have specific values, make that constraint explicit.
PetSize directly drives pricing — a LARGE dog in a suite costs more than a SMALL dog in the same suite. By encoding this in the schema, the pricing engine can use it as a typed input instead of a loose string.
Input Validation Patterns
GraphQL inputs are where most schemas get lazy. A common pattern is accepting nullable fields everywhere and validating in the resolver. This pushes errors downstream and makes the contract unclear.
Instead, use non-nullable inputs for required fields, and split mutations by intent:
Three separate inputs for three distinct business operations. CreateBookingInput doesn't ask for a unit — that's assigned at confirmation. This mirrors the actual 6-step booking flow and prevents invalid state combinations at the schema level.
Role-Based Field Visibility
Not every field should be visible to every role. A customer shouldn't see staff notes. A staff member shouldn't see admin revenue reports. This logic belongs in the resolvers — not in separate schemas.
Field-level resolvers let you apply permissions granularly — a customer can query a booking's status without seeing the internal notes or other customers' data. The schema stays unified; the access control lives in the resolution layer.
Schema Evolution
A schema in production is a contract. Breaking it means breaking clients. Here's how I approach changes without breaking existing queries:
The @deprecated directive signals to clients and tooling that a field is going away — without removing it immediately. This gives frontend teams time to migrate.
Key Takeaways
Your schema should reflect business concepts — bookings, pets, units, roles — not tables and foreign keys. The database is an implementation detail.
If a field can only hold specific values, make it an enum. It documents intent, enables tooling, and prevents invalid states from entering the system.
One mutation per business operation — not one generic update mutation. This makes the API self-documenting and prevents invalid state transitions.
Field-level access control in resolvers is more precise and more secure than route-level guards or separate schemas per role.