Designing API services defines how maintainable and scalable a frontend app becomes.
By applying object-oriented principles and clean architecture, we can create code that's easy to test, extend, and reason about.
Foundation: Service-Oriented API Design
A solid service layer follows the Single Responsibility Principle — each service owns one domain.
The main ApiService
composes all domain services into a single entry point:
export interface IAxiosService {
request<T= any>(config: any): Promise<{ data: T }>;
}
export interface IApiConfig {
API_BASE_URL: string;
API_TIMEOUT: number;
}
export class ApiService {
static inject = ["AxiosService", "AppConfigService"] as const;
constructor(private readonly axios: IAxiosService) {
this.auth = new AuthApiService(this.axios);
this.user = new UserApiService(this.axios);
this.event = new EventApiService(this.axios);
}
readonly auth: AuthApiService;
readonly user: UserApiService;
readonly event: EventApiService;
}
Benefits
- Centralized access point
- Clear domain separation
- Supports Dependency Injection
- Simple unit testing (mock per domain)
Abstract Base Class for Consistency
Define a reusable Axios wrapper that standardizes authentication and error handling. See the auth token management guide for complete implementation details.
Domain Example: Event Service
Each domain should expose only what it owns:
export class EventApiService {
constructor(private readonly axios: IAxiosService) {}
getEvent(id: string) {
return this.axios.request({ method: "GET", url: `/events/${id}` });
}
createEvent(data: ICreateEventData) {
return this.axios.request({ method: "POST", url: "/events", data });
}
}
Dependency Injection Container
A lightweight DI container instantiates and wires all services automatically. See the architecture guide for the complete implementation.
Resilience & Retry Strategy
Use exponential backoff for transient failures:
private async executeWithRetry<T>(op: () => Promise<T>, retries = 3, delay = 1000): Promise<T> {
for (let i = 0; i <= retries; i++) {
try { return await op(); }
catch (e) {
if (i= retries || !this.isRetryable(e)) throw e;
await new Promise(r=> setTimeout(r, delay * 2 ** i));
}
}
}
Testing
Dependency injection makes unit tests clean and independent:
describe("EventApiService", () => {
const mockAxios = { request: jest.fn() } as any;
const service = new EventApiService(mockAxios);
it("fetches an event", async () => {
mockAxios.request.mockResolvedValue({ data: { id: "1" } });
await service.getEvent("1");
expect(mockAxios.request).toHaveBeenCalledWith({
method: "GET",
url: "/events/1",
});
});
});
Use integration tests to validate full flows (auth, caching, etc.).
Optimization & Observability
Caching / Deduplication
private cache = new Map<string, any>();
async getEvent(id: string) {
if (this.cache.has(id)) return this.cache.get(id);
const res = await this.axios.request({ url: `/events/${id}` });
this.cache.set(id, res.data);
return res.data;
}
Logging & Monitoring
Integrate with tools like Sentry for observability.
this.logger.debug("API completed", { url: config.url, status: res.status });
this.bugTracker.captureException(error, { tags: { endpoint: config.url } });
Conclusion
Object-oriented API architecture enables modular, testable, and scalable frontends. Follow clean boundaries, inject dependencies, and standardize error handling.
Architecture isn’t about over-engineering — it’s about clarity and sustainability. Code should be functional and enjoyable to work with.