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 identifiername— string, requireddescription— string, optionalprice— number, required, must be positivecurrency— string, required, ISO 4217 codestock_quantity— integer, required, defaults to 0created_at— datetime, set automatically by the systemupdated_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_caseorcamelCase— pick one, never mix them within the same API. - Use plural nouns for collections (
products, notproduct_list). - Avoid abbreviations that aren’t obvious (
qtyvsquantity— I prefer the full word unless the abbreviation is truly universal). - Keep boolean fields readable as a yes/no question, like
is_activeorhas_discount, rather than ambiguous names likeactiveorflag1.
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_idfield 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.,
pricemust be greater than zero,emailmust match a valid email pattern) - Are there dependencies between fields? (e.g., if
has_discountis true,discount_percentagebecomes required) - What are the allowed values for enums? (e.g.,
statuscan only bepending,shipped,delivered, orcancelled)
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
pageandlimit, 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_amountcan be calculated fromitems, 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_atandupdated_atat 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.