The REST API Maturity Curve: From Junior to Tech Lead

“How your approach to API design evolves as your engineering responsibilities grow”

Photo by RealToughCandy.com: https://www.pexels.com/photo/programmer-holding-a-paper-cutout-with-an-api-quote-11035364/
“The integration worked in staging, but blew up in production.”
“The client relied on a field we never intended to expose.”
“We added a small change and broke three downstream systems.”
The above cases can be quite common when you’re primary work involves a lot of API development. In real-world engineering, how you understand API requirements is just as important as what the requirements say.
I intend to break down the evolution of API understanding through three lenses — Junior, Senior, and Tech Lead — based on how I’ve seen and experienced these roles.
Read this before going further
Titles don’t have a real value unless the associated responsibilities are truly realized. The post is not intended to talk about individual’s performance.
Code snippets written here are not tested. They are just there to explain a point visually.
I’m not explaining the most common problems at each level and fixes. The main idea is to share my interpretation of these roles.
What Do “API Requirements” Actually Mean?
For many, API requirements = endpoint + method + payload + response. That’s the bare minimum. In practice, it includes:
Functional constraints: What the API must do.
Contextual expectations: Why it exists, who uses it, where it fits in the system.
Systemic qualities: Performance, rate limits, error semantics, observability.
Temporality: What breaks if we change it? Is it versioned? Is it backward-compatible?
API endpoints can be assumed like contracts for communication.
Junior Engineer: Fulfilling the Contract
At this stage, you’re mostly writing code as instructed or requested — follow the provided Jira ticket or relevant extract from a big spec document.
Typical focus areas:
HTTP verb and route structure
Payload fields and response types
Return correct status codes
Implement/update the unit tests
Where things go wrong:
Incomplete requirements from your source
Misunderstand optional vs. required fields
Don’t consider how the API is used downstream (e.g. other systems, third-party clients)
Assume “happy path” is sufficient
How I tried to improve
A simple question and a perspective shift helped me improve my solution capability to implement a better solution.
What assumptions were made for this task? (or spec?) and reading the API spec like a consumer, not just a coder
Senior Engineer: Seeing Beyond the Contract
Now you’re thinking about how this API behaves in production, not just if it works.
Your questions evolve:
How do we handle edge cases (e.g. deleted users, archived states)?
Is this exposing sensitive or inconsistent data?
How does this interact with pagination, search, localization?
Should we debounce calls or cache on client/server?
A problem scenario:
Here’s something that illustrates a common real-world problem with leaking internal fields in an API response. You once exposed a GET /users/:id that returned full user objects.
// user.model.ts (Prisma ORM model or internal DB schema)
export const user = await prisma.user.findUnique({ where: { id } });
res.json(user); // Assume this exposes everything, including sensitive/internal fields like isAdmin, lastLoginIp.
Fields like isAdmin, lastLoginIp are unintentionally exposed. If another team starts depending on them, changing those fields later becomes a breaking change.
A common fix to it is using DTOs and field-level control.
// dto/PublicUserDTO.ts
import { Exclude, Expose } from "class-transformer";
export class PublicUserDTO {
@Expose()
id: string;
@Expose()
name: string;
@Expose()
avatarUrl: string;
@Expose()
joinedAt: Date;
@Exclude()
isAdmin: boolean;
@Exclude()
lastLoginIp: string;
constructor(partial: Partial<PublicUserDTO>) {
Object.assign(this, partial);
}
}
// ---------------------------------------------------------
// user.controller.ts
import { plainToInstance } from "class-transformer";
app.get("/users/:id", async (req, res) => {
const user = await prisma.user.findUnique({ where: { id: req.params.id } });
if (!user) return res.status(404).json({ message: "User not found" });
const publicUser = plainToInstance(PublicUserDTO, user, {
excludeExtraneousValues: true,
});
res.json(publicUser); // Only exposes whitelisted fields
});
This keeps your public API contract stable, even as your internal models evolve.
Adding/removing fields becomes a controlled change, not a surprise.
Never forget that if it’s in the API, someone will depend on it. Consider minimal responses from the early stages. Expand as required after careful data-driven decisions
Tech Lead: Designing for Time, Teams, and Trade-offs
At this level, you’re no longer just shipping endpoints; you’re shaping how systems communicate, evolve, and scale across teams.
Your focus shifts:
Will adding a required query param break downstream consumers?
Does this align with the REST patterns used across other teams?
What happens if a dependent service goes down mid-request?
Can we detect and respond to API degradation before users feel it?
Are we truly seeing the consumer perspective?
Often, the most important API requirements are the ones not written down:
What are clients assuming implicitly?
Is this endpoint used synchronously or in batch mode?
Is this API part of a contract with an external partner?
Will this be consumed by mobile apps (with bad network)?
You learn to ask better questions:
“Why does this need to be real-time?”
“What happens if the field is missing?”
“Who else might be affected if we change this?”
“How frequently will the user call this?”
You’re no longer just writing code-you’re designing long-term contracts. And often, the hardest work is navigating ambiguity, aligning cross-team needs, and making intentional trade-offs.
A problem scenario:
You’re building a User Profile API used by internal HR tools, customer-facing dashboards, and reporting systems. Each use case needs different slices of data, and some rely on slow or failure-prone downstream services.
Do you build one endpoint to serve all? Or a clean core endpoint with sidecar APIs?
Initially, you build a single endpoint to serve all needs-but it quickly becomes fragile and hard to evolve.
// One Endpoint Trying to Serve All Use Cases
app.get("/api/v3/users/:userId", async (req, res) => {
const user = await profileService.fetch(userId);
const location = await geoService.lookup(user.locationId); // Assuming it's a 3rd party service, it might fail or be slow
const adminFlags = await hrService.getAdminFlags(user.id); // Not needed by all consumers
res.json({
id: user.id,
name: `${user.firstName} ${user.lastName}`,
email: user.email,
location,
adminFlags,
});
});
The above code is fragile. A single service failure takes down the entire response. Everyone gets all the data, whether they need it or not.
// DTO + Fallbacks is one way to solve this problem
// dto/PublicUserProfileDTO.ts
import { Expose } from "class-transformer";
export class PublicUserProfileDTO {
@Expose() id: string;
@Expose() name: string;
@Expose() avatarUrl: string;
@Expose() location?: string;
static from(user: any, location?: string): PublicUserProfileDTO {
return {
id: user.id,
name: `${user.firstName} ${user.lastName}`,
avatarUrl: user.avatarUrl,
location,
};
}
}
// ---------------------------------------------------------
// user.controller.ts
import { plainToInstance } from "class-transformer";
import { PublicUserProfileDTO } from "../dto/PublicUserProfileDTO";
app.get("/api/v3/users/:userId", async (req, res) => {
onst user = await profileService.fetch(req.params.userId);
const role = req.user?.role || "public"; // Confirm the role
if (role === "admin") {
adminFlags = await hrService.getAdminFlags(user.id);
}
let location: string | undefined;
try {
location = await geoService.lookup(user.locationId);
} catch {
location = undefined; // Fallback. dummy example. Please don't come after me saying it's against Typescript philosophy
}
const response = plainToInstance(
PublicUserProfileDTO,
PublicUserProfileDTO.from(user, location),
{ excludeExtraneousValues: adminFlags } // Get only required content
);
res.json(response);
});
Only explicitly exposed fields are sent; internal schema changes won’t leak into the response.
Having a fallback ensures predictable responses if other services fail.
You can create new DTOs for future API versions without touching core logic.
You’re no longer optimizing just for functionality, but for resilience, collaboration, and change over time.
Because at this level, the question isn’t just “Does the API work?”; it’s “Will it still work X months/years from now, under pressure, across clients?”
Takeaways
Junior engineers think about correctness of their implementation.
Seniors think about robustness and user experience.
Leaders think about longevity and ecosystem alignment.
True API skill isn’t writing endpoints — it’s understanding requirements in multiple dimensions.
Good engineers implement APIs. Great engineers challenge the need for them.
What API lesson left an impact on you at job? Or if you’re earlier in your journey: What part of API design still feels unclear?
Drop a comment or share your favorite war story. Let’s grow together.
Until next time 👋




