How to Design API Data: A Practical Guide to Modeling Resources, Schemas, and Relationships

How to Design API Data: A Practical Guide to Modeling Resources, Schemas, and Relationships

Once I know what my API needs to do — the functional goals I covered in my previous article — the next thing I focus on is data. Data design is the skeleton of an API. If I get this wrong, every endpoint built on top of it inherits the same weaknesses. In this guide, I’ll walk through exactly how I design data for an API, from identifying resources all the way to handling relationships, versioning, and validation.

Why Data Design Comes Before Endpoint Design

A lot of people jump straight into writing routes like GET /users or POST /orders without first deciding what a “user” or an “order” actually looks like as a piece of data. I never do this. I always model the data first, on paper or in a simple document, before I touch any endpoint code. This way, every endpoint I design later is just an operation on data I already understand well.

Step 1: Identify Your Core Resources

A resource is any “thing” your API deals with — a noun, not a verb. Going back to the functional goals I gathered earlier, I pull out every noun that showed up repeatedly: Order, Customer, Product, Payment, Shipment, and so on. Each of these becomes a resource, and each resource usually becomes its own data model and, later, its own group of endpoints.

I try to keep resources focused on a single concept. A common mistake is cramming too much into one resource — for example, storing both “shipping address” and “payment method” directly inside the “Order” resource instead of treating them as their own related resources. This makes the data model rigid and harder to reuse.

Step 2: Define the Attributes of Each Resource

For every resource, I list out its attributes (fields) along with their data type and whether they are required. For a Product resource, this might look like:

  • id — string, required, unique identifier
  • name — string, required
  • description — string, optional
  • price — number, required, must be positive
  • currency — string, required, ISO 4217 code
  • stock_quantity — integer, required, defaults to 0
  • created_at — datetime, set automatically by the system
  • updated_at — datetime, set automatically by the system

I always separate fields that the client is allowed to send from fields that only the system controls. created_at and id, for example, should never be something a client can set directly when creating a resource — the API should generate them.

Step 3: Choose Sensible, Consistent Naming Conventions

Naming might feel like a small detail, but inconsistent naming is one of the most common complaints developers have about APIs. I pick one convention and apply it everywhere:

  • Use snake_case or camelCase — pick one, never mix them within the same API.
  • Use plural nouns for collections (products, not product_list).
  • Avoid abbreviations that aren’t obvious (qty vs quantity — I prefer the full word unless the abbreviation is truly universal).
  • Keep boolean fields readable as a yes/no question, like is_active or has_discount, rather than ambiguous names like active or flag1.

Consistency here matters more than which exact style I choose. A developer integrating with my API should be able to guess a field name correctly most of the time, just by following the pattern used elsewhere.

Step 4: Model Relationships Between Resources

Almost no resource exists on its own. An Order relates to a Customer. A Product relates to a Category. I always map out these relationships explicitly, because they directly affect how I design endpoints and response bodies later. The common relationship types are:

  • One-to-one — a Customer has one Profile.
  • One-to-many — a Customer has many Orders.
  • Many-to-many — a Product can belong to many Categories, and a Category can contain many Products.

For each relationship, I decide how it will be represented in the data:

  • By reference — the Order contains a customer_id field pointing to the Customer resource. The client fetches the full Customer separately if needed.
  • By embedding — the Order response includes the full Customer object nested inside it.

I generally prefer references for large or frequently-changing related data, and embedding for small, stable data that’s almost always needed together. For example, I’ll usually embed a small shipping_address object inside an Order, but I’ll reference the Customer by ID rather than embedding their entire profile.

Step 5: Decide Between Nested and Flat Structures

This is closely related to relationships, but it’s worth calling out on its own. Deeply nested JSON looks tidy at first, but it becomes painful for API consumers to work with once it goes more than two or three levels deep. I try to follow this rule: nest data only when it logically belongs together and is always needed together. If a piece of data might need to be fetched, updated, or paginated independently, it deserves to be its own resource with its own reference, not a nested blob.

Step 6: Plan for Validation Rules at the Data Level

Good data design bakes validation in from the start rather than bolting it on later. For every field, I ask:

  • Is this field required or optional?
  • What is the valid range or format? (e.g., price must be greater than zero, email must match a valid email pattern)
  • Are there dependencies between fields? (e.g., if has_discount is true, discount_percentage becomes required)
  • What are the allowed values for enums? (e.g., status can only be pending, shipped, delivered, or cancelled)

Writing these rules down at the data design stage means the validation logic I build later has a clear specification to follow, instead of being invented on the fly.

Step 7: Design for Pagination and Large Collections From Day One

Any resource that could grow into a large collection — Orders, Products, Logs — needs to be designed with pagination in mind from the very beginning. I decide early whether I’ll use:

  • Offset-based pagination — simple, using page and limit, but can behave oddly if data changes between requests.
  • Cursor-based pagination — more complex to implement, but far more reliable for large or frequently-changing datasets.

I bake this decision into the data model by making sure resources have a reliable, sortable field (usually created_at combined with id) that can be used as a cursor.

Step 8: Think About Versioning Your Data Model Early

Data models change over time — new fields get added, old ones get deprecated. I plan for this early by:

  • Never reusing a field name for a different purpose later.
  • Adding new fields as optional whenever possible, so older clients don’t break.
  • Marking deprecated fields clearly in documentation before removing them, and keeping them around for a transition period.

This kind of discipline in data design is what allows an API to evolve without constantly forcing every consumer to rewrite their integration.

A Worked Example: Designing the “Order” Resource

Let me walk through a real example, pulling together everything above. Based on the functional goals from the previous article (“create an order,” “view order status,” “cancel an order”), my Order resource might end up looking like this:

Order
- id: string (system-generated)
- customer_id: string (reference to Customer)
- items: array of OrderItem { product_id, quantity, unit_price }
- shipping_address: object (embedded, small and stable)
- status: enum [pending, paid, shipped, delivered, cancelled]
- total_amount: number
- currency: string
- created_at: datetime
- updated_at: datetime

Notice how customer_id is a reference (Customers are large, independent resources), items is a small embedded array (order items rarely need to be fetched separately from their order), and status is a strict enum instead of a free-text string, which prevents invalid states from ever being stored.

Common Mistakes I See in API Data Design

  • Storing computed values instead of deriving them. If total_amount can be calculated from items, I still often store it for performance, but I make sure there’s a single source of truth and a clear rule for when it’s recalculated.
  • Using free-text fields where an enum belongs. This leads to inconsistent values like “Shipped”, “shipped”, and “SHIPPED” all existing in the same system.
  • Over-nesting data “just in case.” This makes responses heavier and harder to work with for no real benefit.
  • Forgetting timestamps. Every resource should have created_at and updated_at at a minimum — they’re cheap to add and extremely useful for debugging, sorting, and caching.
  • Not planning for soft deletes. Sometimes “deleting” a resource should really mean marking it inactive rather than removing it, especially for financial or auditable data like Orders and Payments.

Final Thoughts

Good data design is invisible when it’s done well — consumers of your API just find it intuitive and never have to think twice about a field’s meaning or type. But when data design is done poorly, it shows up everywhere: confusing endpoints, inconsistent validation, and painful migrations down the line. I treat this stage as seriously as I treat the functional goals stage, because the two are deeply connected — your data model is really just your functional goals expressed as structured fields.

Total
0
Shares

Leave a Reply

Previous Post
What Do API Designers Do on API Projects? A Complete Breakdown of the Role

What Do API Designers Do on API Projects? A Complete Breakdown of the Role

Next Post
How to Design Goal Success Responses: A Practical Guide to API Response Bodies and Status Codes

How to Design Goal Success Responses: A Practical Guide to API Response Bodies and Status Codes

Related Posts