How to Build a REST API: Contract-First Design, Backend Layers, Testing, and Production Rollout
Building a REST API is not primarily about choosing a web framework. The hard part is turning a product capability into a stable network contract that remains understandable, secure, testable, and compatible while the implementation changes underneath it.
A useful implementation sequence is:
- define the resource and business behavior
- design the HTTP contract
- describe that contract in OpenAPI
- implement transport, business, and persistence boundaries
- enforce validation and authorization
- design transactions, idempotency, and failure behavior
- test the contract and real integrations
- add observability and operational limits
- roll out compatibly
This guide focuses on that build process. REST fundamentals, general REST best practices, and API versioning are covered separately on Lofingo.
1. Start from the product capability
Before creating routes, write down what the user or integration actually needs to do.
Suppose the requirement is:
Customers can create orders, inspect an order, list their recent orders,
and cancel an order before fulfillment begins.
That is more useful than beginning with database tables.
Now identify the domain resources:
Order
Customer
OrderItem
Then define the important operations and invariants:
create order
get one order
list orders for current customer
cancel order if current state allows cancellation
This keeps the API oriented around product behavior instead of exposing internal CRUD mechanically.
2. Design stable resource URIs
A straightforward resource model could use:
POST /orders
GET /orders/{order_id}
GET /orders
POST /orders/{order_id}/cancellation
or a PATCH-based cancellation model if cancellation is naturally represented as a state transition in your contract.
The exact style matters less than consistency and semantics.
Do not expose implementation concepts such as:
/runCreateOrderStoredProcedure
/getOrderFromPrimaryDatabase
The public URI should survive internal refactors.
3. Write request and response examples before writing handlers
For order creation, define the request:
POST /orders
Content-Type: application/json
Idempotency-Key: 01J...
{
"items": [
{
"product_id": "p_42",
"quantity": 2
}
],
"shipping_address_id": "addr_7"
}
Then define success:
HTTP/1.1 201 Created
Location: /orders/o_900
Content-Type: application/json
{
"id": "o_900",
"status": "pending",
"total": {
"amount_minor": 998000,
"currency": "INR"
}
}
And define failure cases before implementation:
invalid quantity
unknown product
insufficient stock
shipping address belongs to another customer
duplicate idempotency key with different payload
Thinking through error semantics early prevents every handler from inventing its own response later.
4. Use OpenAPI as an executable contract
The current OpenAPI Specification is 3.2.0, published in September 2025.
OpenAPI provides a machine-readable description of an HTTP API that can power:
- reference documentation
- client generation
- server stubs
- schema validation
- contract tests
- mock servers
- linting
- security review
A simplified path definition might look like:
openapi: 3.2.0
info:
title: Orders API
version: 1.0.0
paths:
/orders/{order_id}:
get:
operationId: getOrder
parameters:
- name: order_id
in: path
required: true
schema:
type: string
responses:
'200':
description: Order found
'404':
description: Order not found
The OpenAPI document does not replace implementation tests. It gives the organization one explicit contract to validate against.
5. Choose one contract ownership model
Two common workflows are valid.
Contract-first
Design OpenAPI before implementation, review it, then generate or implement server/client code from the approved contract.
This works well when:
- several teams depend on the API
- client and server work happen in parallel
- external consumers need early review
Code-first with contract verification
Implement route/types in code and generate OpenAPI from source annotations/types.
This can work well when:
- one backend team owns the whole surface
- framework tooling is reliable
- CI verifies generated output does not drift unexpectedly
The dangerous model is neither: hand-maintaining documentation separately from production behavior.
Pick one source of truth and test it.
6. Keep transport code thin
A route/controller/handler should mainly translate HTTP into an application operation.
Conceptually:
HTTP handler
parse request
authenticate caller context
validate transport/schema input
call application service
translate result/error to HTTP response
Avoid putting the full business workflow inside the web handler:
HTTP parsing
SQL queries
inventory rules
payment logic
email sending
authorization
response formatting
all in one function.
Thin transport boundaries make the same business operation easier to reuse from jobs, gRPC, CLI/admin tools, or tests without pretending HTTP is the domain model.
7. Put business invariants in an application/domain layer
An order service might own logic such as:
customer may use this shipping address
product is currently purchasable
quantity is allowed
order can transition from pending to cancelled
The HTTP handler should not be the only place those rules exist.
Otherwise, a background worker or second API endpoint can bypass them accidentally.
This does not require heavy “enterprise architecture.” A small service module with clear responsibility is enough.
8. Keep persistence behind explicit data-access boundaries
Your service may need operations such as:
get product for update
create order
list customer orders by cursor
store idempotency result
Whether those operations use PostgreSQL, MySQL, DynamoDB, or another store is an implementation detail.
A repository/query layer is useful when it makes query ownership and testing clearer.
Do not introduce repository abstractions whose only job is renaming every ORM method one-to-one. The abstraction should represent useful application data access, not ceremony.
9. Validate transport shape before business logic
Reject malformed input before executing expensive business work.
Validate:
- content type
- body size
- JSON syntax
- required properties
- field types
- string lengths
- enum values
- numeric ranges
- array lengths
Example product rules:
items must contain 1 to 100 entries
quantity must be 1 to 1000
address_id must be a valid identifier format
A type such as string alone does not protect the service from a 50 MB text field.
Schema validation should include real bounds.
10. Treat authorization as a lookup constraint
One of the highest-risk API mistakes is broken object-level authorization.
Suppose the request is:
GET /orders/o_900
A weak implementation does:
load order o_900
then maybe check whether caller owns it
A safer application query is conceptually:
load order o_900 that is visible to authenticated customer c_42
or apply one centralized authorization policy immediately after lookup and before any sensitive data leaves the domain boundary.
OWASP's API Security Top 10 highlights broken object-level authorization because identifier-based APIs make this mistake easy to introduce.
Authentication proves identity. It does not automatically authorize access to arbitrary object IDs.
11. Separate tenant context from client-controlled fields
For a multi-tenant API, do not let a normal caller choose arbitrary tenant ownership through request JSON:
{
"tenant_id": "someone-elses-tenant"
}
when tenant identity should come from the authenticated session/API key.
Derive trusted context from authentication and use it to scope:
- reads
- writes
- idempotency keys
- cache keys
- rate limits
- audit records
Client-supplied resource IDs are inputs, not authority.
12. Make database transactions match the business boundary
Order creation may need several durable changes:
reserve/decrement inventory
create order
create order items
record outbox event
store idempotency result
When they must succeed or fail together and live in one transactional database, put them in one database transaction.
Keep the transaction short.
Do not hold it open while waiting for:
- payment provider HTTP requests
- email provider
- image processing
- unrelated remote services
Cross-system workflows require different reliability patterns.
13. Use an outbox for reliable post-commit events
Suppose order creation should emit:
OrderCreated
This is unsafe:
commit order
publish event
because the process can crash after commit but before publish.
A transactional outbox writes both:
order rows
outbox event row
inside one database transaction.
A worker later publishes the outbox record and marks/records delivery progress.
Downstream consumers should be idempotent because delivery retries can occur.
This is more reliable than making the API request synchronously call every downstream system.
14. Design POST idempotency before clients need retries
Network outcomes can be ambiguous.
The server may commit successfully and lose the response before the client receives it.
For important create/action requests, accept a stable idempotency key:
Idempotency-Key: checkout_01J...
Within the correct user/tenant scope:
- first request atomically claims/stores the key
- business operation runs
- canonical result is associated with the key
- exact retry returns the original result
- same key with incompatible request body is rejected
Do not implement this as an unsafe “check then insert” race.
Use a unique constraint or equivalent atomic primitive.
15. Decide synchronous vs asynchronous work explicitly
Some operations should return quickly with final state:
create address
update profile
create small order
Others naturally take longer:
generate large export
transcode video
run data import
For long work, create a job resource:
POST /exports
return:
202 Accepted
Location: /export-jobs/job_42
Then expose:
GET /export-jobs/job_42
with states such as:
queued
working
completed
failed
This is easier to retry and observe than holding a request open for several minutes unnecessarily.
16. Build pagination into collection endpoints from day one
This route should not silently return every order:
GET /orders
Use bounded pagination.
For a large mutable collection, cursor pagination might look like:
GET /orders?limit=50&after=opaque_cursor
The server enforces:
default limit: 20
maximum limit: 100
The cursor should correspond to a stable indexed order.
Pagination is part of the public contract; adding it only after the endpoint has millions of rows can break clients that assumed complete results.
17. Keep filter and sort options intentionally finite
Do not turn a REST endpoint into arbitrary SQL through parameters such as:
filter=
sort=
Define supported fields:
status
created_after
created_before
sort=created_at
sort=-created_at
Then design indexes around important combinations.
This improves performance predictability and prevents attackers or accidental clients from constructing extremely expensive queries.
18. Use one error contract across the API
RFC 9457 defines Problem Details for HTTP APIs.
A domain conflict can return:
HTTP/1.1 409 Conflict
Content-Type: application/problem+json
{
"type": "https://api.example.com/problems/order-not-cancellable",
"title": "Order cannot be cancelled",
"status": 409,
"detail": "Fulfillment has already started",
"error_code": "ORDER_NOT_CANCELLABLE"
}
Clients branch on stable machine-readable semantics rather than exact English text.
Internal stack traces and SQL errors belong in server logs, not public responses.
19. Map domain errors to HTTP at the boundary
Your service layer can return domain outcomes such as:
OrderNotFound
OrderNotCancellable
InsufficientStock
PermissionDenied
The HTTP adapter maps them to status + problem response.
This avoids spreading HTTP status codes throughout core business logic.
It also makes the domain easier to expose through a different transport later if needed.
20. Generate safe client SDKs from the contract where useful
OpenAPI can generate typed clients, but generated SDKs should be treated as product code.
Review:
- naming
- optional/null fields
- enum compatibility
- pagination helpers
- error types
- authentication setup
- retry defaults
- timeout defaults
A generated SDK with unsafe automatic retries can create worse behavior than a handwritten thin client.
Generation removes boilerplate; it does not remove API design responsibility.
21. Test at several layers
One test type cannot prove the whole API.
Unit tests
Test pure business rules quickly:
pending order can cancel
shipped order cannot cancel
Repository/integration tests
Test real SQL constraints, transactions, queries, and migrations against the supported database engine.
HTTP integration tests
Start the real route stack and verify:
- authentication
- request parsing
- status codes
- headers
- response schema
Contract tests
Verify production behavior matches OpenAPI and does not introduce unintended breaking changes.
End-to-end tests
Cover a small number of critical full workflows through realistic dependencies.
Do not replace all focused tests with slow end-to-end suites.
22. Test authorization negatively
Security tests should prove what a user cannot access.
For object-level access:
customer A can read A's order
customer A cannot read B's order
customer A cannot update B's order
admin permission behaves according to policy
Test sequential and guessed identifiers, nested subresources, bulk endpoints, exports, and alternate methods.
A happy-path authenticated test does not prove authorization is correct.
23. Test idempotency under concurrency
Do not test only two sequential duplicates.
Send the same idempotency key concurrently and verify exactly one logical operation is created.
Also verify:
- same key + same payload returns same logical result
- same key + different payload is rejected
- expired key behavior is documented
- tenant/user scope cannot collide accidentally
Concurrency is where naive idempotency implementations usually fail.
24. Add compatibility checks to CI
Compare the current OpenAPI document against the last released contract.
Flag changes such as:
- removed paths
- removed response properties
- newly required request fields
- incompatible type changes
- changed enum behavior
- changed security requirements
Some flagged changes may be intentional. The important point is that breaking changes become an explicit review decision rather than an accidental merge.
25. Run migrations separately but compatibly
A production API release may run with old and new application instances simultaneously.
Use expand/contract database changes where possible:
add compatible schema
release code that understands old and new
backfill data in bounded batches
switch reads/writes
remove old schema later
Avoid a release that requires every server and database change to become active in the exact same millisecond.
API availability depends on migration compatibility too.
26. Put operational limits in configuration
A production API should have explicit limits for:
- request body size
- header size through your infrastructure
- page size
- upload size
- request timeout
- database statement timeout
- downstream call timeout
- concurrent expensive jobs
- rate limits and quotas
OWASP's API Security Top 10 includes unrestricted resource consumption because valid-looking API requests can still exhaust compute, memory, storage, bandwidth, or paid downstream services.
Limits are product behavior, not just infrastructure knobs.
27. Rate-limit by meaningful caller and operation
Possible dimensions include:
anonymous IP
user
API key
OAuth application
tenant
operation cost class
A large enterprise tenant and an anonymous login endpoint may need completely different policies.
Return 429 Too Many Requests for enforced request-rate limits and document retry behavior.
Also protect expensive business flows such as:
- password reset
- SMS OTP
- export generation
- AI/media processing
where the real risk is business-resource exhaustion rather than raw HTTP request count.
28. Add route-level metrics before launch
Track normalized route templates:
GET /orders/{id}
POST /orders
GET /orders
not individual customer IDs.
Useful dimensions include:
- request count
- status code/class
- p50/p95/p99 latency
- request/response bytes
- auth failures
- validation failures
- rate-limit rejects
- database latency
- downstream dependency latency
Avoid user IDs, tokens, raw URLs, and free-form search input as metric labels.
29. Use distributed tracing for dependency-heavy endpoints
A trace for order creation should show meaningful operations such as:
POST /orders
authenticate
validate request
inventory database transaction
order transaction
outbox insert
If the request calls remote services, trace those calls too.
Do not create a span for every trivial helper function; trace boundaries that explain latency and failure.
A trace should help answer where the request spent its time.
30. Log enough context without logging secrets
Useful structured log fields include:
- request/trace ID
- normalized route
- method
- status
- duration
- application error code
- service version
Be careful with:
Authorizationheaders- cookies
- API keys
- passwords
- payment data
- full private request/response bodies
Debugging value does not justify leaking credentials into permanent logs.
31. Roll out breaking-risk changes gradually
For a risky implementation change behind the same API contract:
- deploy to a small percentage of instances
- compare latency/errors
- keep rollback easy
- watch business correctness metrics
For an actual new API version:
- run old and new contracts in parallel
- publish migration guidance
- measure client adoption
- deprecate and sunset deliberately
Do not use API versioning to avoid doing safe internal rollouts.
32. Keep OpenAPI and runtime behavior synchronized after release
In CI or post-deployment checks, validate representative operations against the published contract.
Watch for drift such as:
OpenAPI says field nullable
runtime rejects null
OpenAPI says 404
runtime returns 200 with error body
OpenAPI documents cursor pagination
runtime silently uses offset
The contract only creates value if consumers can trust it.
A practical project structure
A small service might use a structure conceptually like:
http/
handlers
request/response mapping
middleware
application/
order service
authorization policies
domain/
entities/value objects
business rules
persistence/
SQL queries/repositories
integration/
payment/inventory clients
api/
OpenAPI contract
Do not copy this structure mechanically.
The principle is to keep transport, business rules, persistence, and external dependencies separable enough to reason about and test.
Production checklist
Before calling a new REST API production-ready, verify:
- resource and workflow behavior are defined before route implementation
- request/response/error examples are reviewed
- OpenAPI matches runtime behavior
- transport handlers do not own all business logic
- schema and business validation have explicit limits
- object/tenant authorization is tested negatively
- local durable changes use correct transaction boundaries
- external side effects use reliable async/outbox patterns where needed
- retryable mutations have concurrency-safe idempotency
- collection endpoints have bounded pagination/filtering
- errors follow one stable machine-readable contract
- unit, integration, contract, and critical end-to-end tests exist
- breaking contract changes are detected in CI
- request size, timeout, rate, and cost limits are configured
- metrics/traces/logs identify failures without leaking secrets
- database migrations and API rollouts are backward compatible
A production REST API is successful when the contract remains stable while the implementation stays free to improve. Designing that boundary deliberately is more important than which HTTP framework created the route.
References
BUILDING A SAAS PRODUCT?
Explore Lofingo’s end-to-end SaaS platforms, multi-tenant cloud architecture, and high-concurrency backend engineering.
EXPLORE SAAS SERVICES →