i.ZAHIR
Loading0%
Back to Blog

I Stopped Copy-Pasting the Same Angular ApiService. Here's What I Built Instead

Four projects in, I was copying the same api.service.ts instead of writing it. The design decisions behind extracting it into @ismailza/ngx-api-client.

Ismail ZAHIR
July 26, 2026
8 min read
Frontend#angular#typescript#architecture#open-source
I Stopped Copy-Pasting the Same Angular ApiService. Here's What I Built Instead

Every Angular project I've worked on had an api.service.ts.

Different repository.

Different company.

Same file.

It always started as a thin wrapper around HttpClient, then gradually accumulated more responsibility: versioning, authentication, retry logic, loading indicators, error handling.

By the fourth project, I wasn't writing it anymore.

I was copying it.

And not carefully. I'd paste the file in, then spend the next hour re-fixing the same three bugs I'd already fixed in the last project — because I'd never written them down anywhere, just patched them in place and moved on.

That was the moment I realized it wasn't repeated code anymore.

A pattern repeated four times isn't a pattern anymore. It's a missing dependency.

So I extracted it, made the hard-coded parts configurable, and published it as @ismailza/ngx-api-client.

This article isn't really about the library. It's about the four decisions that turned a file I was slightly embarrassed to copy-paste into something I'd defend in a code review.

The actual gap

HttpClient is a good HTTP client. That's genuinely all it claims to be, and people keep being disappointed that it isn't more.

HttpClient gives you a request. It does not give you a policy.

Every application still has to answer:

  • Where does the base URL come from, and how does the API version get into it?
  • What turns a failed response into something a component can actually render?
  • Which requests are safe to retry, and how long do you wait before you do?
  • How does a global progress bar find out that anything is in flight?

Most applications answer those questions inside an ApiService that grows organically over time until nobody wants to touch it anymore. I wanted those decisions to live in one place — and to see, at a glance, how a request actually moves through the system:

How a request actually moves through ngx-api-client
How a request actually moves through ngx-api-client

A request leaves the component, passes through ApiService, then through an ordered chain — your auth interceptor, retry with jitter, your backend, error normalization, an optional success step — before looping back. Loading state and error handling hang off that chain as pluggable pieces, not built-in opinions.

The first mistake I kept repeating was hard-coding the version

typescript
`${baseUrl}/api/v1${endpoint}`

It works…

…until your backend decides to version using:

  • headers
  • query parameters
  • media types
  • dates
  • or no URL versioning at all

Instead of treating versioning as string concatenation, I modeled it as a configurable strategy.

typescript
provideApi({
  baseUrl: 'https://api.example.com',
  prefix: 'api',
  version: 2,
  versioning: 'url'
});

The same configuration can instead produce:

  • /api/v2/orders
  • /api/orders?v=2
  • X-API-Version: 2
  • Accept: application/vnd.api.v2+json

without changing application code.

Two rules became surprisingly important:

A header or query parameter the caller set explicitly is never overwritten by the versioning strategy. If you pass Api-Version yourself, you meant it. A library that silently clobbers explicit caller input is a library you cannot debug.

Versions should accept strings as well as numbers. This only became obvious after integrating with a backend that versioned by date:

typescript
provideApi({
  baseUrl: 'https://api.example.com',
  version: '2024-01-01',
  versioning: { strategy: 'header', headerName: 'Api-Version' },
});

If I had typed it as number — which is what my copy-pasted version implicitly assumed — the whole abstraction would have been useless for the exact API that motivated making it configurable.

Then I realized my biggest problem wasn't requests — it was failures

HTTP failures rarely look the same.

Sometimes you get a proper RFC 9457 Problem Details response. Well-behaved, easy.

Sometimes nginx sends back plain text, because it answered before your app ever saw the request.

Sometimes the network disappears completely and the status is simply 0.

Components shouldn't need three different code paths for those three situations. Everything gets normalized into one shape:

typescript
interface ApiError {
  type: string; // RFC 9457 problem type URI
  title?: string; // 'Bad Request'
  status: number; // 0 for a network failure
  detail: string; // safe to show the user
  instance?: string; // path that produced it
  code?: string; // machine-readable, e.g. 'VALIDATION_ERROR'
  timestamp: string;
  traceId?: string; // from the body, else the X-Trace-Id header
  errors?: { field: string; message: string }[];
}

Once a component receives this, it no longer cares where the failure originated.

Two things worth stating out loud:

Branch on code, never on detail. detail is prose meant for a human. It gets reworded, translated, and A/B tested. The day somebody changes "Invalid credentials" to "Incorrect email or password," every if (error.detail === "Invalid credentials") in your codebase silently stops working.

typescript
if (error.code === "INVALID_CREDENTIALS")

code is the contract. detail is for humans.

traceId falls back to the X-Trace-Id header. Nine times out of ten the useful correlation ID is in the response headers and the error body is empty. If your error model discards headers, every production bug report starts with "can you reproduce it?"

I almost shipped a toast notification. I'm glad I didn't

That was the easiest feature to add, and the hardest to leave out on purpose.

The moment a library renders anything, it has an opinion about your design system, your i18n setup, and your accessibility strategy — three things it cannot possibly know. So the library only exposes an error handler that applications replace:

typescript
@Injectable()
export class ToastApiErrorHandler extends ApiErrorHandler {
  private readonly toast = inject(MyToastService);
  private readonly router = inject(Router);

  override handle(error: ApiError): void {
    this.toast.error(error.detail, { title: error.title });
    if (error.status === 403) {
      this.router.navigate(['/forbidden']);
    }
  }
}

The HTTP layer reports problems. The application decides how users experience them.

The practical payoff: peer dependencies are @angular/core, @angular/common, and rxjs. Nothing else. A library sitting in your HTTP layer has no business pulling a UI kit into your bundle.

One honest caveat I put in the README rather than hiding: the default handler passes the whole ApiError to Angular's own ErrorHandler, which logs it. If your API puts sensitive data in detail or instance, register your own handler instead of relying on the default. Defaults should be safe, and where they can't be, they should say so.

One decision surprised every reviewer — I don't register my own interceptors

typescript
provideHttpClient(
  withInterceptors([
    retryInterceptor,
    apiErrorInterceptor,
    apiSuccessInterceptor
  ])
);

This is the one I had to defend hardest in review. At first glance it looks like boilerplate the library should absorb. It isn't, because order matters and only you know what else is in the chain:

  • An auth interceptor has to come first, so a retried request picks up a fresh token instead of replaying the expired one.
  • retryInterceptor has to come before apiErrorInterceptor, so your error handler only ever sees failures that survived every retry. Get this backwards and users see three toasts for one eventually-successful request.

If the library silently injected interceptors at a position it chose, that position would be wrong in some app, and debugging it would mean reading my source. Making the ordering explicit costs one line and buys the ability to reason about the chain.

Retry is harder than it looks

typescript
retry(3)

That's five characters and it isn't enough. A few rules that all turned out to matter:

  • Don't retry POST requests by default. It isn't idempotent — replaying it can create two orders. Callers opt in per request when they know their endpoint is safe.
  • Respect the server's Retry-After header. On a 429, the server has told you exactly when to come back; your backoff curve is a guess, the header isn't.
  • Retry only transient failures: 408, 429, 500, 502, 503, 504. Retrying a 400 is just asking the server to reject you four times.
  • Use exponential backoff — and always add jitter.
typescript
const exponentialDelay = config.initialDelay * Math.pow(config.multiplier, retryIndex - 1);
const jitter = exponentialDelay * Math.random() * 0.3;
return timer(exponentialDelay + jitter);

Without jitter, every client that hit your struggling backend at the same moment retries at the same moment, and again two seconds later, and again four seconds after that. You've built a synchronized load test against a server that's already failing. A little randomness breaks the lockstep.

Two smaller decisions I'm glad I made

Per-request configuration travels through HttpContext, not service state.

typescript
this.api.get('/orders', { version: 1 });
typescript
this.api.post('/analytics/event', payload, {
  retry: false,
  skipErrorHandler: true,
  showLoader: false,
});

One request can disable retries, skip global error handling, or bypass loading indicators without touching any concurrent request. A mutable flag on the service that the next interceptor reads would be a race condition waiting for two calls to happen at once.

Loading state is a counter, not a boolean.

typescript
@Injectable({ providedIn: 'root' })
export class ApiLoadingService {
  private readonly activeRequests = signal(0);
  readonly loading = computed(() => this.activeRequests() > 0);
}

A boolean flickers: three requests start, the fastest one finishes, and the progress bar disappears while two are still running. A counter with a computed() signal doesn't. It's a five-line class, and it's the single most-copied snippet from every version of this file I ever wrote.

What I would tell myself before starting

Don't extract after the first implementation. Extract after the fourth. Three implementations taught me which parts actually varied — the version transport, the error presentation — and which parts never did. Abstracting after one project would have produced configuration options nobody needed, and hard-coded the things that mattered.

Write the README before the code you're unsure about. Every section I struggled to explain was a section where the API was wrong. The interceptor-ordering paragraph took three rewrites, and the third is what convinced me not to auto-register them.

Be honest about compatibility. Mine says: developed and tested against Angular 21; the declared floor of >=17 reflects the APIs used — signals and functional interceptors — rather than a range the CI matrix currently covers. Less impressive than claiming full 17–21 support. Also true, which matters more.

Closing thoughts

Open source isn't about writing perfect software. It's about taking something you've solved the same way four times, pulling out the pattern, and inviting other people to tell you where it's wrong.

I'm not sure this library is done evolving. But the ideas behind it — versioning as a strategy instead of string concatenation, one error shape no matter what the backend sends back, keeping presentation out of the HTTP layer, and making interceptor order something you choose rather than something the library hides from you — are decisions I'd make again.

I'm curious how other Angular teams handle this. Do you build your own API layer, or lean directly on HttpClient? Which of these decisions would you have made differently?

This is my first technical article, and I'd genuinely love feedback. If you think one of these design decisions is wrong — or you've solved the problem differently — I'd enjoy hearing your perspective.

The library is on npm as @ismailza/ngx-api-client, and the source is on GitHub if you'd like to explore the implementation.

— Ismail

Originally published on Medium

Share:Share on Twitter / XShare on LinkedInShare on Facebook