Best Practices for Designing Operations for Multiple Roles (Customer/Employee)

Hi folks! :waving_hand:

Let’s imagine an application for scheduling service appointments.

A Customer can use the mobile app to create an appointment, but the same appointment can also be scheduled by an Employee (or Receptionist) on behalf of a customer via the company’s internal system.

I’m exploring schema design options for the createAppointment mutation and would love to hear your thoughts.


Option 1: Single mutation for all roles

type Mutation {
  createAppointment(data: CreateAppointmentInput!): CreateAppointmentResponse!
}
  • This mutation is used by both customers and employees.
  • If the user is a customer, their customerId is inferred from the token/context.
  • If the user is an employee, the input needs to include a customerId.

The problem: A customer could pass a customerId manually, even though they shouldn’t. It opens the door to misuse if not strictly validated.


Option 2: Role-specific mutations

type Mutation {
  createAppointmentByCustomer(
    data: CreateAppointmentByCustomerInput!
  ): CreateAppointmentResponse! @auth(roles: [CUSTOMER])

  createAppointmentByEmployee(
    data: CreateAppointmentByEmployeeInput!
  ): CreateAppointmentResponse! @auth(roles: [EMPLOYEE, ADMIN])

  # Optional default
  createAppointment(
    data: CreateAppointmentInput!
  ): CreateAppointmentResponse!
}
  • Clear separation of use cases.
  • Inputs can be tailored for each role.
  • Easier to apply role-based logic or validation at the schema level.
  • If we have multiple features, the root Query and Mutation types can become overloaded with fields, making it harder to navigate or organize operations efficiently.

Option 3: Role-based namespacing

type Mutation {
  employees: EmployeesMutation! @auth(roles: [EMPLOYEE, ADMIN])
  customers: CustomersMutation! @auth(roles: [CUSTOMER])
}

type EmployeesMutation {
  createAppointment(data: CreateAppointmentByEmployeeInput!): CreateAppointmentResponse!
}
  • Encapsulates mutations by role.
  • Makes it clear at the top level who is allowed to perform what.
  • Scales well if each role has many operations.

This logic also applies to queries (e.g., listing appointments, orders, etc.). An employee would need to pass the customerId, but the customer shouldn’t.

How would you do?

Of those three options, I would personally choose between Option 1 and Option 2.

I would generally stick with Option 1. However, I wouldn’t decide when to infer the customer ID or not. I would instead always take the customer ID from the input variable and then use authorization methods to determine if the caller is allowed to do that on behalf of the ID. In the simplest terms, this means that if you do not have the EMPLOYEE or ADMIN role, then the customer ID MUST match the ID in the token.

Option 2 is good if there are different requirements for when a customer creates an appointment versus an admin or employee creating an appointment, such as sending additional SMS notifications when an employee or admin schedules your appointment for you or something. Yes, there may be drift on features, but if it is more likely that you want them to be different than stay in sync, then it’s probably a good option. However, if you more or less want them to function the same, then option 1 is probably better.

Contract variants may also be another option to use here, whether in option 1 or option 2. I am not sure off the top of my head if you can exclude variables in contract variants, but maybe you could exclude the customer id input from a customer variant.

I would generally avoid Option 3 (though I have seen this been done before) because while it looks nice on paper, I don’t enjoy having to parse an input object after the mutation starts to be able to determine what they want done. I also think it opens yourself up to weird null scenarios and parallel mutation scenarios since everything would pretty much have to be nullable on the first level. I haven’t done this exact thing, so I don’t know the pain of it (or the benefit), but it feels like it doesn’t need to be done, especially with contract variants being available.

2 Likes