Part Six of the Series: Building the Right Software Systems
At the beginning of any project, designing an API seems very simple. We have a user who wants to create an order: POST /orders
They send the data, we create the order, and return the result. Done. But the project grows. After some time, we have Mobile applications, Web applications, Admin interfaces, and perhaps external partners depending on the API. Then we discover that changing the name of a small field can break an entire application. Adding a new value to a Status can affect a Client that wasn't prepared for it. An old Endpoint cannot be removed because someone is still using it.
And suddenly the question becomes: How do we change the system without breaking the people and systems that depend on it? This is when we understand that an API is not just a collection of Endpoints. It is a Contract between different parts of a system.
An API Is a Contract Between Two Parties
When a Client uses our API, it makes assumptions about it. If it sends:
{ "product_id": 42, "quantity": 2 }
and expects to receive:
{ "id": 1001, "status": "pending" }
then we already have a contract, even if we never formally documented it anywhere. The problem is that the contract doesn't live only in the code. It may live in a Mobile application, Frontend, another service, an external Integration, a Script, or even a system built by another team. Therefore, we should treat every public API as if someone else will depend on it for a long time.
Design for Clarity, Not Cleverness
It is easy to try to make an API "smart." We add shortcuts, implicit behavior, many Parameters, and responses that change depending on certain conditions. Eventually, the API becomes difficult to use. A good API shouldn't require the consumer to be an expert in the internal system. It should be clear. If you want to create an order, it should be obvious:
What should I send?
What will I receive?
What states are possible?
What happens if the operation fails?
The more a developer has to guess about the behavior of an API, the weaker the contract becomes.
Don't Make the API a Direct Reflection of the Database
A common mistake is taking a table and turning it directly into an API. We have: users
So we create:
GET /users POST /users PUT /users/:id DELETE /users/:id
Then we do the same thing for every table. This approach may work at first, but an API is not a Database Interface. An API should represent what the consumer needs, not necessarily how we store data internally. If the database changes tomorrow, we shouldn't have to change the API simply because the storage structure changed.
The API Should Reflect the Domain
Imagine a subscription management system. The user doesn't think in terms of: subscription_user_plan_records
They think:
"My subscription."
"I want to cancel my subscription."
"I want to change my plan."
This difference matters. A good API speaks the language of the Domain. For example:
POST /subscriptions POST /subscriptions/{id}/cancel POST /subscriptions/{id}/change-plan
This may be clearer than a generic Endpoint that allows everything to be modified: PUT /subscriptions/{id}
There is no rule saying the first approach is always better. The important question is: Does the API clearly express the operations that the system supports?
Don't Rely on HTTP Status Codes Alone
The Status Code is important, but in real systems, consumers need to know more than simply receiving a 400.
What happened? Is the data invalid? Which field? And why?
Errors should ideally be understandable and programmatically actionable. For example:
{ "error": { "code": "INSUFFICIENT_STOCK", "message": "The requested quantity is not available." } }
Now the Client can handle INSUFFICIENT_STOCK consistently, while the human-readable message can change later without breaking the code.
Don't Make Error Messages the Contract
A common mistake is for the Frontend to depend on a message such as: "Product is out of stock" and search for that exact sentence to determine what happened. Tomorrow, we might change it to: "Requested quantity is unavailable" and the behavior breaks.
That's why we should have a stable Error Code, while the message is intended for humans. It's a small rule, but it prevents a lot of problems as the system grows.
What Happens When We Need to Change the API?
This is where the real difficulty begins. Suppose we have:
{ "name": "Ahmed", "phone": "123" }
Then we decide that phone should become:
{ "phone_number": "123" }
We could simply rename the field. But what about all the existing Clients? If five applications depend on phone, we've just broken all five at once. That's why we need to treat an API as something that has a lifecycle.
Backward Compatibility
One of the most important skills in building APIs that last is knowing how to introduce changes without breaking what already exists. Adding a new field is usually easier than removing an existing field. Adding a new value to an Enum can be more dangerous than it appears. Changing the type of a field can break Clients. Changing the meaning of an existing field is even more dangerous than changing its name. So before making any change, ask: Is this a Breaking Change? If it is, we need to handle it carefully.
Versioning Is Not the Solution to Everything
When we hear about Breaking Changes, the usual solution appears:
/api/v1 /api/v2
Versioning is useful, but it shouldn't become an excuse to break the API constantly. If we create a new Version for every small modification, we'll eventually have:
v1
v2
v3
v4
v5
And we have to maintain all of them. That adds significant cost. It's better to make most changes Backward Compatible, and introduce a new Version when the change is significant enough to justify it.
APIs Need Idempotency
Another problem appears with sensitive operations. What happens if a Client sends the same request twice? Imagine a payment operation. The Client sends: POST /payments
Then a Timeout occurs. The Client doesn't know whether the operation succeeded or not, so it tries again. Will we charge the customer twice? This isn't a theoretical problem. In financial and commercial systems, these scenarios must be taken seriously.
This is where the concept of Idempotency becomes important, allowing the system to recognize that the second request represents the same operation rather than a new one.
Don't Assume the Network Is Reliable
Inside local code, calling a Function seems simple: createOrder()
But when the call happens over a Network, new problems appear:
Timeout
Connection failure
Duplicate request
Partial failure
Retry
Service unavailable
So we should always remember: Network calls are not function calls. There is a significant difference between calling a function inside the same process and calling another service over a network. That difference becomes extremely important once the system starts depending on multiple services.
Retry Can Be Dangerous
When a Request fails, we may think the solution is simple: "Retry it". But what if the operation actually succeeded and we simply didn't receive the response? If we repeat the operation without protection, we may execute it twice. Therefore, Retry isn't simply: try again
We need to think about:
Is the operation safe to execute again?
Do we need an Idempotency Key?
Is the failure temporary or permanent?
How many times should we retry?
Should we use Backoff?
These details are what make an API suitable for real-world systems.
Authentication Is Not Authorization
A common mistake when designing APIs is confusing two different concepts. Authentication: Who are you? and Authorization: What are you allowed to do?
A user may be successfully authenticated, but that doesn't mean they are allowed to: DELETE /users/123
Every operation should therefore have clear Permissions. Simply saying:
"The user is Logged in" is not enough.
Don't Expose More Data Than the Client Needs
If a Client requests user data, that doesn't mean we should return everything. The User Model may contain internal information that should never be exposed externally, such as:
Internal identifiers
Permissions
Internal flags
Sensitive metadata
The API Response should be designed for the consumer. We don't want: "Send the Model as it is". We want: "Send the data this consumer actually needs."
Documentation Is Not Optional
If an API is important, it needs to be documented. A developer shouldn't have to guess:
What is the Endpoint?
What are the Parameters?
What are the data types?
What errors are possible?
What Authentication is required?
What are the different response scenarios?
Good documentation reduces dependence on knowledge that exists only inside one person's head. This is especially important for systems that operate for years.
Don't Forget Observability
When a Client fails to use the API, we need to understand what happened. We should be able to trace a Request through the system. This is where the importance of the following becomes clear:
Logs
Metrics
Tracing
Correlation IDs
This becomes especially important when an operation passes through multiple Services. If a user says: "The order failed". We should be able to answer: "Where did it fail?" and Not simply: "It looks like the API returned 500."
A Good API Hides Internal Complexity
This is an important point. The internal system may be extremely complex, but the Client doesn't need to know that. Creating an order internally might involve:
Order Service → Inventory → Payment → Notification → Database
But the Client simply needs a clear API: POST /orders
This is the value of an API. It is a layer that separates the consumer from the internal complexity. If the Client needs to understand how the internal system works, we've lost an important part of the value provided by the abstraction.
Principles to Remember
When designing an API, ask:
Is the API clear to someone who didn't write the system?
Does it represent the Domain or the database?
Can the internal system change without breaking it?
Are errors programmatically actionable?
Are new changes Backward Compatible?
Are sensitive operations protected against duplication?
Do we understand the risks of Network Failures?
Are Authentication and Authorization separate?
Are we sending only the data the Client needs?
Is the API documented?
Can we understand what happened when a Request fails?
Conclusion
An API is not simply: URL + HTTP Method + JSON. It is a contract. And the more people and systems depend on it, the more expensive it becomes to change. That's why we should design APIs with a long-term mindset. The goal isn't to make an API smart or complicated. The goal is to make it: Clear, stable, evolvable, and difficult to use incorrectly. Most importantly: Design your API as if someone else will depend on it for years, because that someone might be your own team two years from now.
In the next article, we'll move to a topic that directly builds on this idea: Complexity and Technical Debt. Why does complexity accumulate even in good teams? When is Technical Debt a reasonable engineering decision? And when does it become a risk that slows down the entire project?
An API is more than an Endpoint that receives data and returns a response—it is a contract between a system and the consumers that depend on it. This article explores how to design APIs that are clear, stable, and easy to evolve, while handling changes, errors, Idempotency, Authentication, and Observability without exposing or coupling consumers to internal system details.
Scalability doesn't mean building the biggest system possible from day one, nor does it mean ignoring the future until problems appear. This article explores how to think about scalability pragmatically—when to keep things simple, when to introduce more advanced architecture, and how to design systems that can evolve when real growth arrives.
A well-structured software system isn't defined by its folders or layers, but by the clarity of its components and their responsibilities. In this article, we explore practical principles for dividing a system into maintainable, loosely coupled components that make software easier to understand, extend, and evolve over time.
Every great software system starts with a clear understanding of the problem, not the technology. In this article, we explore why problem analysis should come before solution design, how asking the right questions leads to better engineering decisions, and why understanding the business is the foundation of building software that lasts.