Capstone 2: Data Modeling and API Contracts
The previous article froze what to build. This one delivers two artefacts you can actually work from: a schema that runs and an API specification someone can code against. They sound humble; in practice these are the two most failure-prone steps on the whole site — pick the wrong column type and finance cannot reconcile half a year later, refuse to change objects between layers and one schema edit detonates the frontend. Every decision here comes with the live scene of what breaks if it is wrong, and every claim can be run yourself.
Five terms, one line each:
- DDL: the whole
CREATE TABLEstatement — it tells the database which columns exist, what type each holds and which values may never repeat - Index: the alphabetical directory at the front of a dictionary. Reads get fast, but every insert and update has to maintain that directory too, so writes get slower
- DTO / Entity / VO: three look-alike objects with different jobs — the DTO receives what the user sent, the Entity maps one database row, the VO is what the frontend is allowed to see
- Idempotent: doing the same thing ten times yields exactly the result of doing it once. The single test: does a repeat execution cause any extra side effect?
- Contract: the request/response specification both sides sign, fixing field names, types, requiredness and which error code a failure carries
the same batch of ingredients, three shapes. You buy the makings of "tomato and egg stir-fry" and the vendor hands you a purchase slip — muddy tomatoes, a whole carton of eggs, price tag still attached. That is the DTO: things arrive shaped by the outside world's rules. In the kitchen they get washed, chopped and packed into a labelled prep box, gaining a batch number and an expiry date. That is the Entity: sorted into the storehouse's own slots. What reaches the table is a finished dish with no mud and no price tag. That is the VO: only the presentable form leaves. Projects that "pass one entity class all the way through" effectively carry that muddy crate, box and all, onto the customer's table — internal flags, delete markers and password hashes on open display. Section 6 turns this chain into real code.
one cinema ticket must never be printed twice. The window print fails, so you ask the staff to "run it again". A correct system does not hand you a second seat with the same number; it hands back the one it already issued, because it keys on the ticket number, not on "you happened to ask again". BeeOrder's checkout idempotency has exactly this shape: the client generates one idempotentKey per checkout intent, the database puts a unique index on it, a duplicate submit collides with that index, and the service catches the collision and returns the first order unchanged. The clever-looking alternative — "query first whether it exists, then decide to insert" — lets two people both read "it does not exist" and both write an order. Two voices shouting "print it again" at the window really do lose the seat. Section 8 is devoted to this.

This map doubles as the article's table of contents: the resource/path and method branches plus the status-code and error-code branches land in Sections 5 and 7, paging and idempotency live in Sections 9 and 8, and the bottom "versioning" branch is that repeated rule "endpoints may only grow, never change". As you read, keep one thought in mind: any element left unstated guarantees somebody knocks on your door on integration day — the error-code table in Section 7 and the quick-reference in Section 13 are those answers written down in advance.
After this article you should be able to answer three questions:
- Why must order items redundantly store the product name and unit price? What happens to historical orders once a product is renamed?
- Why must money be
DECIMAL(12,2)plusBigDecimalrather thandouble? Give one concrete miscalculation. - What is wrong with "check for a duplicate first, then decide whether to insert"? What would make it genuinely idempotent?
The previous article froze BeeOrder's domain model, state machine and API list. This one turns them into two deliverables: the database schema and the API contract. But before writing a single CREATE TABLE, get the order right — the schema is the consequence of the domain model, never its starting point.
Many code smells begin with "design the tables first": take a requirement, draw an ER diagram, add whatever fields are convenient, and the business rules end up with nowhere to live. The correct order is: think through the business rules and aggregate boundaries, then let tables carry them.
Three modeling principles:
- Domain first, tables second: domain objects (User / Order / Inventory) are the subject; tables are only their persisted form. A concept that does not exist in the domain should not appear out of nowhere in a table.
- Model per aggregate: one aggregate root equals one main table plus several child tables; child tables point at the root and may only be changed through it.
orderandorder_itemform one aggregate;userandorderare two. - Reference other aggregates by ID, not by a hard foreign key:
order.user_idstores only the user ID, with noFOREIGN KEYconstraint. This is not laziness; it buys decoupling and room to scale — hard foreign keys add locking and cascade risk under high concurrency.
Third normal form (3NF) says eliminate redundancy: store each fact once. Yet for orders we deliberately break 3NF — order_item redundantly stores product_name and product_price.
| Scenario | Normal form choice | Reason |
|---|---|---|
| Basic user and product info | follow 3NF | maintain once, reference everywhere; one edit applies globally |
| Item name and unit price | denormalize (snapshot) | a historical order must freeze the facts as of checkout |
| Order total total_amount | redundant aggregate | avoids SUM(order_item) on every read, and the total is itself a business fact |
To make this concrete, imagine a scene: on May 1 a user buys a "Hive Smart Speaker" for 5999. On June 1 marketing renames it "Hive Speaker Pro" and drops the price to 4999. If order_item stored only product_id, then when the user views the order in July, when the platform settles June refunds, and when finance issues invoices, the program joins product and reads the new name and new price — the user objects that they paid 5999, and reconciliation falls apart.
A snapshot is not "duplicate data"; it is "the facts as of that moment". Whenever something "was once this price, this name, this address", it must be frozen at the instant the business event happens. Understand this and you understand why order systems are naturally a hotspot for denormalization.
Put both designs on the table and settle the bill in one go — strict 3NF on the left, BeeOrder's targeted redundancy on the right:

Do not pick a side yet; just compare two ledgers: what the left saves is storage, and what it forfeits is history plus query speed; what the right spends is two extra columns, and what it buys is history that never mutates plus one query per page. Reduced to a single judgement: should this field still read as it did three years from now? If yes, snapshot it; if no (marketing copy, for instance), join it live and honestly.
denormalization is not license to duplicate at will — only duplicate fields that change over time and whose historical values must be preserved. A product's marketing copy can be joined live; its checkout price must be snapshotted.
Eight tables spread across two aggregate domains; see the whole picture first, then land each table:

BeeOrder uses MySQL 8, always with the InnoDB engine (we need transactions and row locks) and the utf8mb4 charset (we need emoji and Chinese), with DATETIME(3) for millisecond precision. Three of those land inside the DDL; the fourth — connection and runtime configuration — lands in application.yml. Have the generator lay out its skeleton first:
server:
port: 8080
spring:
application:
name: beeorder
datasource:
url: jdbc:mysql://127.0.0.1:3306/bee_order?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
username: ${DB_USER:root} # ${} 占位符:环境变量优先,冒号后是默认值
password: ${DB_PASS:}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
max-lifetime: 1740000 # 必须小于 MySQL 的 wait_timeout
pool-name: beeHikari
Note two things about the output: the millisecond precision of DATETIME(3) survives only if the connection parameters preserve it, and the serverTimezone=Asia/Shanghai the generator writes is merely a default — leave it alone for now; Section 10 explains why the whole project converges on UTC.
CREATE TABLE `user` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'user id, auto-increment primary key', `phone` VARCHAR(20) NOT NULL COMMENT 'phone number, the login account', `password` CHAR(60) NOT NULL COMMENT 'BCrypt hash, fixed length 60, never plaintext', `nickname` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'display name', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 active, 0 disabled', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT 'soft delete: 0 no, 1 yes', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'created at', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'updated at', PRIMARY KEY (`id`), UNIQUE KEY `uk_user_phone` (`phone`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci COMMENT = 'user table';| Field | Type | Why this choice |
|---|---|---|
| id | BIGINT UNSIGNED | auto-increment primary key is the clustered index; ordered writes mean fewer page splits, and unsigned widens the range |
| phone | VARCHAR(20) + unique index | the login account must be unique; 20 chars covers international numbers |
| password | CHAR(60) | BCrypt outputs a fixed 60 chars, so CHAR avoids a length prefix; never plaintext |
| status / deleted | TINYINT | only a couple of values, one byte is plenty, smaller than VARCHAR |
| create_time | DATETIME(3) | millisecond precision helps debug concurrency and gives stable ordering |
CREATE TABLE `product` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'product id', `name` VARCHAR(128) NOT NULL COMMENT 'product name', `subtitle` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'subtitle', `price` DECIMAL(12,2) NOT NULL COMMENT 'unit price in CNY, exact money type', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 listed, 0 delisted', `extra_info` JSON NULL COMMENT 'extra info (tags/images), open structure', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT 'soft delete', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'created at', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'updated at', PRIMARY KEY (`id`), KEY `idx_product_status` (`status`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'product table';| Field | Type | Why this choice |
|---|---|---|
| price | DECIMAL(12,2) | money must be exact; double silently loses precision in its binary form (see the demo in Section 4) |
| extra_info | JSON | tags and images have an open shape; MySQL 8's native JSON validates and offers query functions |
| status | TINYINT | listed/delisted is binary, ideal for a low-cardinality filter |
CREATE TABLE `inventory` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'inventory id', `product_id` BIGINT UNSIGNED NOT NULL COMMENT 'product id', `available` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'sellable quantity; unsigned rejects negatives', `locked` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'locked quantity (ordered, unpaid)', `version` INT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'optimistic-lock version', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'updated at', PRIMARY KEY (`id`), UNIQUE KEY `uk_inventory_product` (`product_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'inventory table';| Field | Type | Why this choice |
|---|---|---|
| product_id | BIGINT UNSIGNED + unique index | one row per product; the unique index is both a constraint and a fast lookup path |
| available | INT UNSIGNED | under strict mode an unsigned column errors out before going negative — a database-level backstop |
| locked | INT UNSIGNED | lock on checkout, convert on payment, release on timeout — all revolve around it |
| version | INT UNSIGNED | a spare optimistic-lock counter, leaving room for later concurrency schemes |
CREATE TABLE `inventory_log` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'ledger id', `product_id` BIGINT UNSIGNED NOT NULL COMMENT 'product id', `change_qty` INT NOT NULL COMMENT 'delta: negative on deduct, positive on restore', `type` VARCHAR(16) NOT NULL COMMENT 'DEDUCT / RELEASE / RESTORE', `ref_id` BIGINT UNSIGNED NOT NULL COMMENT 'related id (order id and so on)', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'created at', PRIMARY KEY (`id`), KEY `idx_invlog_product_time` (`product_id`, `create_time`), UNIQUE KEY `uk_invlog_ref_type` (`ref_id`, `type`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'inventory ledger';| Field | Type | Why this choice |
|---|---|---|
| change_qty | INT (signed) | one entry encodes direction: negative to deduct, positive to restore |
| type | VARCHAR(16) | readable enum names make debugging obvious at a glance |
| (ref_id, type) | composite unique key | one id, one action can be written only once — deduction idempotency for free |
order is a SQL keyword, so the table name must be back-quoted — the first trap this article insists you remember (see Section 10).
CREATE TABLE `order` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'order id', `order_no` VARCHAR(32) NOT NULL COMMENT 'public business order number', `user_id` BIGINT UNSIGNED NOT NULL COMMENT 'buyer user id', `total_amount` DECIMAL(12,2) NOT NULL COMMENT 'order total in CNY', `status` VARCHAR(16) NOT NULL COMMENT 'CREATED/PAID/SHIPPED/COMPLETED/CLOSED/REFUNDED', `idempotent_key` VARCHAR(64) NOT NULL COMMENT 'client-supplied idempotency key', `receiver_name` VARCHAR(32) NOT NULL DEFAULT '' COMMENT 'recipient name', `receiver_phone` VARCHAR(20) NOT NULL DEFAULT '' COMMENT 'recipient phone', `receiver_addr` VARCHAR(255) NOT NULL DEFAULT '' COMMENT 'shipping address', `pay_time` DATETIME(3) NULL COMMENT 'paid at', `ship_time` DATETIME(3) NULL COMMENT 'shipped at', `close_time` DATETIME(3) NULL COMMENT 'closed at', `expire_time` DATETIME(3) NOT NULL COMMENT 'auto-close at = created + 30 minutes', `deleted` TINYINT NOT NULL DEFAULT 0 COMMENT 'soft delete', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'created at', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'updated at', PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), UNIQUE KEY `uk_order_idem` (`user_id`, `idempotent_key`), KEY `idx_order_user_status_time` (`user_id`, `status`, `create_time`), KEY `idx_order_status_expire` (`status`, `expire_time`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'order table';| Field | Type | Why this choice |
|---|---|---|
| order_no | VARCHAR(32) + unique index | the public order number; never expose the auto-increment ID's size or growth rate |
| status | VARCHAR(16) | stores enum names, matching the state machine exactly, so debugging is readable |
| idempotent_key | VARCHAR(64) | the client key; the unique (user_id, idempotent_key) makes checkout idempotent |
| expire_time | DATETIME(3) + composite index | the scan basis for auto-close, stored so it need not be recomputed each time |
| total_amount | DECIMAL(12,2) | the total is a business fact; storing it avoids summing items on every read |
CREATE TABLE `order_item` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'item id', `order_id` BIGINT UNSIGNED NOT NULL COMMENT 'order id', `product_id` BIGINT UNSIGNED NOT NULL COMMENT 'product id', `product_name` VARCHAR(128) NOT NULL COMMENT 'snapshot of product name at checkout', `product_price` DECIMAL(12,2) NOT NULL COMMENT 'snapshot of unit price at checkout', `quantity` INT NOT NULL COMMENT 'quantity', `amount` DECIMAL(12,2) NOT NULL COMMENT 'line total = price x quantity', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'created at', PRIMARY KEY (`id`), KEY `idx_item_order` (`order_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'order item table';| Field | Type | Why this choice |
|---|---|---|
| product_name / product_price | VARCHAR + DECIMAL (snapshot) | a historical order must not shift when a product is renamed or repriced |
| amount | DECIMAL(12,2) | line total = price x quantity; storing it avoids recomputation |
| order_id | plain index | loading items by order is the hottest read path and needs an index |
CREATE TABLE `payment` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT 'payment id', `order_id` BIGINT UNSIGNED NOT NULL COMMENT 'order id', `trade_no` VARCHAR(64) NOT NULL COMMENT 'gateway trade number, the idempotency key', `amount` DECIMAL(12,2) NOT NULL COMMENT 'paid amount', `channel` VARCHAR(16) NOT NULL DEFAULT 'MOCK' COMMENT 'payment channel', `status` VARCHAR(16) NOT NULL COMMENT 'INIT/SUCCESS/FAILED', `callback_time` DATETIME(3) NULL COMMENT 'callback arrival time', `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'created at', `update_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'updated at', PRIMARY KEY (`id`), UNIQUE KEY `uk_payment_trade_no` (`trade_no`), KEY `idx_payment_order` (`order_id`)) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'payment table';| Field | Type | Why this choice |
|---|---|---|
| trade_no | VARCHAR(64) + unique index | the gateway's trade number is unique and is the core line of defence for callback idempotency |
| status | VARCHAR(16) | INIT/SUCCESS/FAILED, working with the order state machine to guarantee "succeed once" |
| callback_time | DATETIME(3) | records when the callback actually arrived, useful for reconciliation |
All eight tables are in place. Before touching indexes, watch one real checkout walk between the tables: which step lands on which table, and which step must keep its hands off the others. This animation is the shared blueprint for Sections 3 through 9:

An index is not "add one to every commonly used field". Each secondary index slows writes (every insert/update maintains another B+ tree), so the rule is: serve only the genuinely hot query paths.
Here is the actual index DDL — beyond the ones inlined above, the order table needs two composite indexes:
-- My order list: by user + status + time, descendingALTER TABLE `order` ADD KEY `idx_order_user_status_time` (`user_id`, `status`, `create_time`);-- Auto-close scan: filter by status, then take the earliest expirationsALTER TABLE `order` ADD KEY `idx_order_status_expire` (`status`, `expire_time`);A composite index (user_id, status, create_time) is sorted "by user_id first, then by status within a user, then by create_time within that". So it can only be used by conditions that start at the leftmost column and stay contiguous:
| Query condition | Uses the index? | Note |
|---|---|---|
WHERE user_id = ? | yes | uses the leftmost column |
WHERE user_id = ? AND status = ? | yes | first two columns, contiguous |
WHERE user_id = ? AND status = ? ORDER BY create_time DESC | perfect | all three columns, and the sort rides the index |
WHERE status = ? | no | skips the leftmost user_id; the index is no longer ordered by status |
That last row is the iron law of the leftmost prefix: a composite index's column order decides which queries it can serve. Put the most selective column (user_id) leftmost so more queries benefit.
a WHERE status = ? query on a single low-cardinality column cannot lean on the composite index. If you truly need to filter by status alone, give it its own (status, ...) index, or accept a full scan.
| Field that should not be indexed | Reason |
|---|---|
| deleted | cardinality of 2 is nearly useless; make it a subordinate column of a composite index, not a standalone one |
| status (alone) | in millions of orders it has low selectivity; a single-column index barely helps |
| create_time (alone) | range scans cross many rows; better as the last column of a composite index |
| extra_info (JSON) | indexing JSON bloats the index and restricts usage; extract a real column if you must search it |
| frequently updated fields | every UPDATE maintains the index, causing write amplification for little benefit |
a good rule of thumb — indexes serve "read-heavy + high-selectivity" conditions. Conversely, if a field changes on nearly every write, or a query matches more than ~20% of the table, the index will not help.
Here is the per-table reasoning collected into one table:
| Business field | Choice | Alternative | Why (and why not the alternative) |
|---|---|---|---|
| Money | DECIMAL(12,2) | DOUBLE / FLOAT | floats represent decimals in binary and cannot express 0.1 exactly; sums drift |
| Status | VARCHAR(16) enum name | TINYINT number | readable, self-explaining, easy to debug; costs a few extra bytes |
| Time | DATETIME(3) (stored UTC) | TIMESTAMP / BIGINT | decoupled from business time zone, no 2038 problem, millisecond precision |
| Primary ID | BIGINT UNSIGNED auto-increment | UUID / snowflake | ordered writes, clustered-index friendly; expose order_no externally |
| Extension | JSON type | VARCHAR / TEXT | MySQL 8 native JSON validates, offers functions, and reads clearly |
| Delete flag | deleted TINYINT | physical delete | users/products may soft-delete; orders are financial records and never do |
| Optimistic lock | version INT | no version, row locks only | leaves room for read-modify-write paths; row locks and optimistic locks both available |
You do not need to be intimidated by the word "precision" — one line of code explains it:
System.out.println(0.1 + 0.2); // 0.30000000000000004System.out.println(new java.math.BigDecimal("0.1") .add(new java.math.BigDecimal("0.2"))); // 0.3System.out.println(2.99 * 100); // 298.99999999999994- Line 1:
doublecannot represent 0.1 or 0.2 exactly, so the sum is0.30000000000000004 - Line 2: constructing
BigDecimalfrom strings yields exactly 0.3 - Line 3: even the ubiquitous "yuan to cents" conversion goes wrong in floating point
The database-side answer is DECIMAL(12,2): 12 total digits with 2 decimals reaches 99,999,999,999.99, plenty for business, and maps to BigDecimal in Java. Any stray double in money-handling code is a potential source of financial loss.
use float / double only where an approximation is inherently expected — coordinates, ratings, ratios. For money, stock and quantities, always use exact types.

The previous article listed 15 endpoints; this one adds "what the request looks like, what the response looks like, what the auth is", so it becomes a contract both sides can code against. URLs follow REST conventions: plural nouns, hierarchy for ownership, filtering and paging in the query string, and no verbs in the path.
| Method | Path | Auth | Request focus | Response focus |
|---|---|---|---|---|
| POST | /api/v1/auth/register | public | phone + password | new user id |
| POST | /api/v1/auth/login | public | phone + password | JWT + user info |
| GET | /api/v1/auth/me | auth | none (read from JWT) | current user DTO |
| GET | /api/v1/products | public | page / size / keyword | paged product VOs |
| GET | /api/v1/products/{id} | public | path param id | product detail VO |
| GET | /api/v1/inventory/{productId} | public | path param productId | available / locked |
| POST | /api/v1/orders | auth | CreateOrderRequest + idempotency key | new order VO |
| GET | /api/v1/orders | auth | OrderQuery (status/time/paging) | paged order VOs |
| GET | /api/v1/orders/{id} | auth | path param id | order detail VO |
| POST | /api/v1/orders/{id}/cancel | auth | path param id | updated order VO |
| POST | /api/v1/payments | auth | orderId + channel | payment record and redirect info |
| POST | /api/v1/payments/callback | public (signed) | gateway callback payload | fixed success ack |
| GET | /api/v1/payments/{orderId} | auth | path param orderId | payment record VO |
| GET | /api/v1/notifications | auth | page / size | paged notification VOs |
| GET | /api/v1/admin/stats/orders | admin | begin / end date | daily aggregate VOs |
A contract with only a table is not enough; the caller should grasp it at a glance. Here are the three most critical endpoints as full payloads.
The checkout request — note the client idempotency key idempotentKey is required:
POST /api/v1/ordersAuthorization: Bearer eyJhbGciOiJIUzI1NiJ9...Content-Type: application/json{ "receiverName": "Zhang San", "receiverPhone": "13800138000", "receiverAddr": "Building 1, 802, Science Park, Nanshan, Shenzhen", "idempotentKey": "9f2c1e0a-3b7d-4c11-8e2a-create-order", "items": [ { "productId": 1001, "quantity": 2 } ]}The success response — data holds an OrderVO with no userId, no deleted and no internal fields:
{ "code": 0, "message": "ok", "data": { "orderNo": "202610071530001234", "status": "CREATED", "totalAmount": 11998.00, "expireTime": "2026-10-07T07:30:00Z", "items": [ { "productId": 1001, "productName": "Hive Smart Speaker", "productPrice": 5999.00, "quantity": 2, "amount": 11998.00 } ] }, "traceId": "a1b2c3d4", "timestamp": 1759827000000}The failure response — insufficient stock returns error code 1001, and data carries diagnostics for the frontend and for debugging:
{ "code": 1001, "message": "insufficient stock", "data": { "productId": 1001, "available": 1, "requested": 2 }, "traceId": "e5f6a7b8", "timestamp": 1759827004321}Once the contract is frozen, the "parse → validate → dispatch" chain that a request travels through Spring takes a definite shape: path parameters go through @PathVariable, the body goes through @RequestBody plus Bean Validation, and both merge on the controller method signature.
Four labs take that chain apart. Lab 1: the stop where validation fails — swap the success payload above for one missing a required field and watch where the request dies and what comes back:
Lab 2: where objects change vehicles. The "purchase slip → prep box → plated dish" picture from Section 6 is not decoration; it maps onto real type conversions. Switch to the cost-of-skipping-a-layer position to see what happens when they never change:
Lab 3: how a Java object becomes the JSON you saw. The response body does not appear by magic — the message converter (HttpMessageConverter, the component that translates a return value into JSON or a string according to the Accept header) decides its shape:
Lab 4: the error exit. That is the whole point of the error-code table in Section 7 — a business exception needs exactly one exit, otherwise the frontend receives an HTML stack trace instead of an envelope:
the value of a contract is that it is fixed before coding. Once frozen, the frontend can start immediately with mock data and the backend implements to the contract; if they disagree during integration, read the contract instead of assigning blame — precisely the "contract first" idea the animation's six steps convey.
Now replay those three payloads against the animation a second time; each frame lands on one concrete artefact: frame 1's use case is the "submit an order" story card from #43 Section 2; frame 2 produces the CreateOrderRequest in Section 5 (fields, types and constraints fixed together); frame 3 is the OrderVO plus the error-code table; frame 4 is the endpoint list in this very section; frames 5 and 6 are both teams building separately and finally matching over curl. Whichever frame feels empty tells you which deliverable has not been written yet:

The contract is on paper; the tooling is alive. Boot the app into the kernel console and run the paths that matter most in this contract: one idempotency key arriving twice, the paging SQL rewrite, and the message converter translating the same response:
Many projects cut corners by returning the database entity Order straight from the controller. It is fine in a demo; in production it is three landmines:
| Risk of returning an Entity | Consequence |
|---|---|
| Security leak | the entity carries password, deleted and internal flags; serializing it leaks them |
| Tight coupling | change the schema (add/rename a column) and the frontend API breaks |
| Circular reference | Order and OrderItem reference each other; Jackson serialization overflows the stack |
So BeeOrder strictly separates three layers: Entity maps the database, DTO receives requests, VO emits responses. Their fields may look nearly identical, but their responsibilities and lifecycles differ completely.
Click the transfer points of the three objects in one checkout, and you see exactly what the separation fences off. Note the last stop: skipping a transfer does not raise an error — it walks internal fields out of the building:
The request-side DTO carries "shape and validation rules", stopping bad input at the controller door:
package com.beeorder.order.dto;import jakarta.validation.Valid;import jakarta.validation.constraints.*;import java.util.List;public class CreateOrderRequest { @NotBlank(message = "recipient name is required") @Size(max = 32) private String receiverName; @NotBlank(message = "recipient phone is required") @Pattern(regexp = "^1[3-9]\\d{9}$", message = "invalid phone format") private String receiverPhone; @NotBlank(message = "shipping address is required") @Size(max = 255) private String receiverAddr; @NotEmpty(message = "order items are required") @Size(max = 50, message = "at most 50 products per order") @Valid private List<Item> items; @NotBlank(message = "idempotency key is required") @Size(max = 64) private String idempotentKey; public static class Item { @NotNull(message = "product id is required") private Long productId; @NotNull @Min(1) @Max(999) private Integer quantity; // getters / setters omitted } // getters / setters omitted}The response-side VO exposes only "fields the frontend needs and that are safe", and does the masking here:
package com.beeorder.order.vo;import java.math.BigDecimal;import java.time.LocalDateTime;import java.util.List;public class OrderVO { private String orderNo; // business number only, never the auto-increment id private String status; // state-machine enum name private BigDecimal totalAmount; private List<OrderItemVO> items; private String receiverName; private String receiverPhone; // masked to 138****8000 on output private LocalDateTime createTime; private LocalDateTime payTime; // note: no userId (frontend does not need it), no deleted, no version}A thin, hand-written converter maps Entity to VO — visible, controllable, debuggable:
package com.beeorder.order.converter;public final class OrderConverter { private OrderConverter() {} public static OrderVO toVO(Order order, List<OrderItem> items) { OrderVO vo = new OrderVO(); vo.setOrderNo(order.getOrderNo()); vo.setStatus(order.getStatus()); vo.setTotalAmount(order.getTotalAmount()); vo.setReceiverName(order.getReceiverName()); vo.setReceiverPhone(MaskUtil.phone(order.getReceiverPhone())); // mask vo.setCreateTime(order.getCreateTime()); vo.setPayTime(order.getPayTime()); vo.setItems(items.stream().map(OrderConverter::toItemVO).toList()); return vo; }}| Mapping option | Pros | Cons | Fits |
|---|---|---|---|
| Hand-written converter | zero dependencies, visible logic, breakpoint-friendly | repetitive when there are many fields | BeeOrder: few fields with masking and custom logic |
| MapStruct | generated at compile time, effortless with many fields | needs an annotation processor; complex mappings still need custom code | large projects where DTO and Entity align closely |
BeeOrder writes converters by hand. The reason is practical — our mapping carries masking, status translation and field assembly, which MapStruct would still handle with custom methods. Hand-writing keeps it simple and direct.
Every endpoint wraps its payload in a Result<T> envelope, so the frontend can branch on a single code:
package com.beeorder.common.response;public class Result<T> { private int code; // 0 means success private String message; private T data; private String traceId; // trace id for debugging private long timestamp; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 0; r.message = "ok"; r.data = data; r.timestamp = System.currentTimeMillis(); return r; } public static <T> Result<T> fail(ErrorCode ec) { Result<T> r = new Result<>(); r.code = ec.getCode(); r.message = ec.getMessage(); r.timestamp = System.currentTimeMillis(); return r; } // getters / setters omitted}Error codes live in one enum, each bound to an HTTP status, so monitoring, the gateway and logs all understand them:
package com.beeorder.common.response;public enum ErrorCode { OK(0, "ok", 200), STOCK_NOT_ENOUGH(1001, "insufficient stock", 409), ORDER_NOT_FOUND(1002, "order not found", 404), ORDER_STATUS_ILLEGAL(1003, "order status does not allow this action", 409), DUP_PAY_CALLBACK(2001, "duplicate payment callback, ignored", 200), PAY_AMOUNT_MISMATCH(2002, "payment amount does not match the order", 400), UNAUTHORIZED(3001, "not authenticated or token expired", 401), FORBIDDEN(3002, "no permission for this resource", 403), PARAM_INVALID(4001, "parameter validation failed", 400), INTERNAL_ERROR(5000, "system busy, please retry later", 500); private final int code; private final String message; private final int httpStatus; ErrorCode(int code, String message, int httpStatus) { this.code = code; this.message = message; this.httpStatus = httpStatus; } // getters omitted}BeeOrder divides codes into segments, so a prefix tells you the family at a glance: 1xxx business (orders/stock/payment), 2xxx payment callbacks, 3xxx auth, 4xxx validation, 5xxx system.
| Code | Meaning | HTTP | Trigger |
|---|---|---|---|
| 1001 | insufficient stock | 409 | at checkout, available < requested |
| 1002 | order not found | 404 | query or cancel a non-existent order |
| 1003 | illegal order status | 409 | start a payment on a closed order |
| 2001 | duplicate payment callback | 200 | a second callback for the same trade_no (idempotent, returns success) |
| 2002 | payment amount mismatch | 400 | callback amount differs from the order amount |
| 3001 | unauthenticated | 401 | missing or expired token |
| 3002 | forbidden | 403 | accessing someone else's order |
| 4001 | validation failed | 400 | Bean Validation fails |
| 5000 | internal error | 500 | an uncaught runtime exception |
"always 200, hide the error in the body" is a common legacy habit. It saves the frontend a little work but blinds the gateway's retry policy, monitoring's error-rate stats and cache invalidation. Give a business error the HTTP status it deserves; the code in the envelope only adds finer business semantics.
Idempotency is one of the easiest places in a distributed system to get wrong and one of the easiest to overlook. BeeOrder has two writes that must be idempotent: checkout and the payment callback.
First see what "arriving twice" looks like in the system: the second request carrying the same idempotency key bounces off the unique index for its whole journey. Every frame of this animation maps onto a line of code in 8.1 below:

A user double-clicks "submit order", or the client retries after a timeout, and the same checkout intent arrives twice. The most reliable defence is not "query first to see if it exists" but letting a database unique constraint shut the duplicate out:
@Transactionalpublic OrderVO createOrder(Long userId, CreateOrderRequest req) { try { // assemble and insert; uk_order_idem on (user_id, idempotent_key) guards it Order order = assemble(userId, req); orderMapper.insert(order); return doCheckout(order, req); // deduct stock, write items } catch (DuplicateKeyException e) { // unique-key conflict = a duplicate submit: fetch and return the first result Order exist = orderMapper.selectByUserIdAndIdemKey(userId, req.getIdempotentKey()); log.info("duplicate checkout, returning first result orderNo={}", exist.getOrderNo()); return OrderConverter.toVO(exist, orderItemMapper.selectByOrderId(exist.getId())); }}-- the foundation of idempotency: one user, one idempotency key, one rowUNIQUE KEY `uk_order_idem` (`user_id`, `idempotent_key`)idempotent_keyis generated by the client (a UUID), one key per checkout intent, reused on retry- the unique
(user_id, idempotent_key)guarantees the duplicate insert fails, andDuplicateKeyExceptionis the "duplicate" signal - on conflict, do not error out — return the first result; to the user, two clicks yield the same order, and that is idempotency
类比|Analogy: the idempotency key is the ticket number printed on the corner. Before reprinting, the window reads the number and honours only the first request for it — so "print it again" returns the same seat rather than a second one. Moving that rule from paper into the database costs exactly the two lines above: a unique index nails duplicates down. Remove it and two people shouting "print it again" at once really do get two tickets: one extra order row plus one extra stock deduction.
But do not turn the page yet: the snippet above catches the signal after the fact, and the intuitive rewrite many people reach for is check-then-insert. Walk that version line by line with two threads, and the millisecond-wide window cannot hide any more:
String key = req.getIdempotentKey();Order exist = orderMapper.selectByUserIdAndIdemKey(userId, key);if (exist != null) { return OrderConverter.toVO(exist, orderItemMapper.selectByOrderId(exist.getId()));}Order order = assemble(userId, req);orderMapper.insert(order);return doCheckout(order, req);OrderService.createOrder(OrderService.java:38)OrderController.create(OrderController.java:38)The verdict stands next to the code: every line of check-then-insert is fine on its own, but together they make the database a promise the database never made. Section 8.2 applies the same idea to payment callbacks, with trade_no as the line of defence.
A payment gateway resends callbacks often (it retries when it never receives your success ack). Two lines of defence: the unique payment.trade_no blocks a duplicate row, and the order state machine blocks a duplicate transition:
@Transactionalpublic void handleCallback(PayCallbackDTO dto) { Payment pay = paymentMapper.selectByTradeNo(dto.getTradeNo()); if (pay != null && PayStatus.SUCCESS.name().equals(pay.getStatus())) { log.info("duplicate payment callback, ignored tradeNo={}", dto.getTradeNo()); return; // first line: already succeeded, return } // second line: conditional update, only CREATED may move to PAID int rows = orderMapper.markPaid(dto.getOrderId(), OrderStatus.CREATED, OrderStatus.PAID); if (rows == 0) { log.warn("order status does not allow payment, ignoring orderId={}", dto.getOrderId()); return; } paymentMapper.updateStatus(dto.getTradeNo(), PayStatus.SUCCESS); inventoryService.commitLocked(dto.getOrderId()); // convert locked stock to deducted}The three idempotency points and their key fields:
| Scenario | Idempotency key | Constraint |
|---|---|---|
| Checkout | order.idempotent_key (client UUID) | unique (user_id, idempotent_key) |
| Payment callback | payment.trade_no (gateway number) | unique uk_payment_trade_no + status check |
| Stock deduction | inventory_log (ref_id, type) | unique uk_invlog_ref_type |
idempotency is essentially "constrain a mutable operation with an immutable fact". trade_no and idempotent_key are externally given and inherently unique; make them unique indexes and you get a defence that is concurrency-safe and independent of application logic.
List endpoints return a PageResult<T>, never a bare array — the frontend needs total to render a pager:
package com.beeorder.common.response;import java.util.List;public class PageResult<T> { private List<T> list; private long total; // total rows private int page; // current page, starting at 1 private int size; // page size private int pages; // total pages private boolean hasNext; // whether a next page exists public static <T> PageResult<T> of(List<T> list, long total, int page, int size) { PageResult<T> r = new PageResult<>(); r.list = list; r.total = total; r.page = page; r.size = size; r.pages = (int) ((total + size - 1) / size); r.hasNext = (long) page * size < total; return r; } // getters / setters omitted}Conditional queries gather into a single query object, keeping controller signatures free of scattered parameters:
package com.beeorder.order.dto;import java.time.LocalDateTime;public class OrderQuery { private String status; // optional: filter by status private LocalDateTime beginTime; // optional: created-at lower bound private LocalDateTime endTime; // optional: created-at upper bound private Integer page = 1; private Integer size = 20; // server-side cap 100 private Long userId; // injected from JWT, never from the client! public int getOffset() { return (Math.max(page, 1) - 1) * Math.min(size, 100); } // getters / setters omitted}Paging parameters, fixed as a team convention:
| Parameter | Default | Cap | Note |
|---|---|---|---|
| page | 1 | — | starts at 1; anything below 1 is treated as 1 |
| size | 20 | 100 | anything above 100 is truncated to 100 |
| sort | create_time desc | whitelist | only whitelisted fields, to block injection and inefficient sorts |
without a size cap, an attacker's single ?size=1000000 drags the whole table out and blows up both the database and your memory. The server must cap the page size; on any public endpoint this is not optional.
-- wrong: MySQL parses order as part of ORDER BY and reports a syntax errorSELECT * FROM order WHERE id = 1; -- ERROR 1064-- right: wrap it in back-quotes to declare it an identifierSELECT * FROM `order` WHERE id = 1;The same applies to MyBatis: the @TableName("order") on the entity, and every hand-written SQL in XML, must carry the back-quotes. Miss one place and you may one day see You have an error in your SQL syntax in production.
Section 4 demonstrated the float error. The team rule: money is always DECIMAL plus BigDecimal, and yuan-to-cent conversions use BigDecimal.movePointLeft/Right — no double anywhere.
This is where teams argue most, so BeeOrder fixes the convention: store UTC in the database, use LocalDateTime (regarded as UTC) in the application, and render in the user's time zone at the display layer:
- Database: set the MySQL session
time_zone = '+00:00'and addconnectionTimeZone=UTCto the JDBC URL - Application: use
LocalDateTime/Instantconsistently, never a mix withjava.util.Date - Display: the frontend receives ISO-8601 strings with
Zand renders in the browser's local zone
The payoff: a multi-region deployment never shows "the same order with different times on different machines", and daylight-saving switches do not shift anything.
Section 9 settled on "cap size at 100 on the server". But where exactly should that number sit? Too small and ordinary users complain; too large and the database does your overtime — and attackers always check first whether you left it uncapped. The sandbox below turns "rows fetched per request" into three positions, and each shows response time, connection occupancy and the cost of computing total on one screen:
GET /api/v1/orders?size=100 -> 21msApplication peak memory: 96MBUses idx_order_user_status_time, so the sort avoids filesortCOUNT(*) hits the covering index: 1.8msAnything above 100 is truncated and echoed back as size=100, so the frontend can tell
the criterion for the cap is not "how much am I willing to give", it is "how much can one screen render, plus how much can the network carry". When a bulk export is genuinely needed, generate a file asynchronously — never turn it into a synchronous query with a large size.
A warm-up on the float trap from Section 4:
Then a comprehensive one, tying Sections 2, 3 and 8 together:
One last piece of evidence before the wrap-up. The duplicate checkout below is the log line every newcomer fears — red text, a stack trace, and yet no extra order. Treat it as an on-site inspection: who blocks, whether the block is right, and which layer of your code must catch the signal:
The user double-clicked submit, so the second request arrives carrying the same idempotency key. This stack shows up in the log while the order count stays put — an on-site inspection, not a panic.
this article turned the previous "contract" into two deliverables. On the schema side, we built eight tables per aggregate and explained why order items snapshot prices, why money must be DECIMAL, and why indexes only work from the leftmost prefix. On the API contract side, we completed all 15 endpoints with request/response/auth, a uniform Result<T> envelope, segmented error codes, and idempotency for checkout and payment callbacks via unique indexes plus the state machine. The foundation of BeeOrder is now complete — next article we write the core business that actually runs on top of these tables and contracts: checkout, atomic stock deduction, payment callbacks and auto-close on timeout.